From 073c9ad7f0c5b8eab8fdac1d03cfbc1bb2b08c39 Mon Sep 17 00:00:00 2001 From: James Coleman Date: Wed, 8 Jul 2026 17:20:38 -0500 Subject: [PATCH] Add address-set support to APF/CSF and ipset reboot persistence to iptables iptables: persist ipsets across reboot to match rule persistence. - Detect the ipset save-file + restore unit (RHEL ipset.service / /etc/sysconfig/ipset; Debian netfilter-persistent / /etc/iptables/ipsets), non-fatally. - After each set mutation, `ipset save` into the layout's save-file and auto-enable a present-but-disabled restore unit; warn when no mechanism exists (sets stay live-only). - Use ListUnitFiles (not ListUnitFilesByPatterns, which needs systemd >= 230; CentOS 7 ships 219). APF/CSF: gain address sets by persisting ipset commands in the pre-hook. - The hook carries an `ipset create/flush/add` block ordered ahead of the `-m set --match-set` rule lines, so the firewall recreates the set on every (re)start before any rule references it. - Route set-referencing rules (Source/Destination names an ipset) through the hook rather than a literal trust-file line (ruleNeedsHook/bareHostShape). - Implement the six address-set methods, advertise AddressSets, and wire sets into Backup/Restore via captureBackupState/restoreBackupSets. Validated live: reboot simulation for iptables; generated-hook source for APF/CSF. Unit tests cover the hook ipset round-trip, ordering, in-use guard and set-ref routing; the capability-gated integration subtest now covers APF/CSF. --- apf_linux.go | 66 +- capabilities_linux_test.go | 28 +- csf_linux.go | 1360 +++++++++--------- firewall.go | 30 +- firewalld_linux.go | 577 ++++---- hooks_linux.go | 230 ++- hooks_linux_test.go | 135 ++ iptables_linux.go | 2713 +++++++++++++++++++----------------- iptables_linux_test.go | 5 + nft_linux.go | 1587 +++++++++++---------- pf.go | 1968 +++++++++++++------------- scripts/refactor_order.py | 2 +- ufw_linux.go | 874 ++++++------ wf_windows.go | 203 ++- 14 files changed, 5153 insertions(+), 4625 deletions(-) diff --git a/apf_linux.go b/apf_linux.go index 14df16d..c688254 100644 --- a/apf_linux.go +++ b/apf_linux.go @@ -144,7 +144,7 @@ func (f *APF) Capabilities() Capabilities { RuleOrdering: false, DefaultPolicy: false, RuleCounters: false, - AddressSets: false, + AddressSets: true, Comments: true, } } @@ -1907,9 +1907,14 @@ func (f *APF) Backup(ctx context.Context, zoneName string) (*Backup, error) { if err != nil { return nil, err } - // Backup captures the full filter and NAT rule state; Restore removes the - // current rules and re-adds these, so every rule read is preserved. - return &Backup{Rules: rules, NATRules: natRules}, nil + // Backup captures the full filter and NAT rule state plus the hook's address + // sets; Restore removes the current rules and re-adds these, so every rule read + // is preserved. + backup := &Backup{Rules: rules, NATRules: natRules} + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil } // Restore replaces the managed rules with the contents of a Backup. @@ -1938,6 +1943,13 @@ func (f *APF) Restore(ctx context.Context, zoneName string, backup *Backup) erro } } + // Recreate the address sets before the rules so a set-referencing rule resolves + // when apf sources the hook. The old rules are already gone, and editAddressSet + // rewrites each set's block idempotently, so cleanFirst is unnecessary. + if err := restoreBackupSets(ctx, f, backup, false); err != nil { + return err + } + // Re-add rules from backup. for _, r := range backup.Rules { if err := f.addRule(ctx, zoneName, r, false); err != nil { @@ -1962,34 +1974,56 @@ func (f *APF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *Def return unsupportedPolicy(f.Type()) } -// GetAddressSets is unsupported: apf has no address-set support. +// GetAddressSets returns the address sets carried by the apf pre-hook. func (f *APF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { - return nil, unsupportedSet(f.Type()) + return f.hook().getAddressSets() } -// GetAddressSet is unsupported: apf has no address-set support. +// GetAddressSet returns a single address set by name, or an error if absent. func (f *APF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { - return nil, unsupportedSet(f.Type()) + sets, err := f.hook().getAddressSets() + if err != nil { + return nil, err + } + for _, s := range sets { + if s.Name == name { + return s, nil + } + } + return nil, fmt.Errorf("address set %q not found", name) } -// AddAddressSet is unsupported: apf has no address-set support. +// AddAddressSet writes a set as ipset commands in the pre-hook; apf --restart +// (Reload) sources the hook to create the set. Re-adding a set is idempotent. func (f *APF) AddAddressSet(ctx context.Context, set *AddressSet) error { - return unsupportedSet(f.Type()) + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + changed, err := f.hook().editAddressSet(set, false) + f.ConfigChanged = f.ConfigChanged || changed + return err } -// RemoveAddressSet is unsupported: apf has no address-set support. +// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a +// hook rule still references the set. func (f *APF) RemoveAddressSet(ctx context.Context, name string) error { - return unsupportedSet(f.Type()) + changed, err := f.hook().editAddressSet(&AddressSet{Name: name}, true) + f.ConfigChanged = f.ConfigChanged || changed + return err } -// AddAddressSetEntry is unsupported: apf has no address-set support. +// AddAddressSetEntry adds an entry to an existing set in the pre-hook. func (f *APF) AddAddressSetEntry(ctx context.Context, name, entry string) error { - return unsupportedSet(f.Type()) + changed, err := f.hook().editAddressSetEntry(name, entry, false) + f.ConfigChanged = f.ConfigChanged || changed + return err } -// RemoveAddressSetEntry is unsupported: apf has no address-set support. +// RemoveAddressSetEntry removes an entry from an existing set in the pre-hook. func (f *APF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { - return unsupportedSet(f.Type()) + changed, err := f.hook().editAddressSetEntry(name, entry, true) + f.ConfigChanged = f.ConfigChanged || changed + return err } // Reload restarts apf to apply config changes, but only when a mutation changed its files. diff --git a/capabilities_linux_test.go b/capabilities_linux_test.go index 4927abb..816ce7b 100644 --- a/capabilities_linux_test.go +++ b/capabilities_linux_test.go @@ -14,12 +14,11 @@ func TestCSFAPFStillUnsupported(t *testing.T) { // Logging, rate limiting, connection-state and interface matching are now // expressed by injecting iptables rules through the pre-hook (see - // TestHookScriptRoundTrip), so those route to the hook rather than being - // rejected. What remains genuinely unsupported: address sets and explicit - // rule ordering on both backends, plus source NAT on CSF. + // TestHookScriptRoundTrip), and address sets by persisting ipset commands in + // that same hook, so those route to the hook rather than being rejected. What + // remains genuinely unsupported: explicit rule ordering on both backends, plus + // source NAT on CSF. for _, m := range []Manager{csf, apf} { - require.ErrorIs(t, m.AddAddressSet(ctx, &AddressSet{Name: "x"}), ErrUnsupportedSet, - "%s should reject address sets", m.Type()) require.ErrorIs(t, m.InsertRule(ctx, "", 1, &Rule{Port: 22, Proto: TCP, Action: Accept}), ErrUnsupportedOrdering, "%s should reject explicit ordering", m.Type()) // NAT ordering is unsupported on both even though they store NAT rules. @@ -41,9 +40,9 @@ func TestSentinelErrors(t *testing.T) { ctx := context.Background() csf := &CSF{} - // NAT, policy and address-set rejections carry their specific sentinel. CSF - // supports destination NAT (csf.redirect) but not source NAT, so a SNAT rule - // carries the NAT sentinel. + // NAT and policy rejections carry their specific sentinel. CSF supports + // destination NAT (csf.redirect) but not source NAT, so a SNAT rule carries the + // NAT sentinel. nat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"} err := csf.AddNATRule(ctx, "", nat) require.ErrorIs(t, err, ErrUnsupportedNAT) @@ -52,9 +51,6 @@ func TestSentinelErrors(t *testing.T) { _, err = csf.GetDefaultPolicy(ctx, "") require.ErrorIs(t, err, ErrUnsupportedPolicy) - err = csf.AddAddressSet(ctx, &AddressSet{Name: "x"}) - require.ErrorIs(t, err, ErrUnsupportedSet) - // The shared per-rule reject helper wraps sentinels too (used by the wf backend). err = (&Rule{Proto: TCP, Port: 22, Action: Accept, Log: true}).rejectLogAndLimit("csf") require.ErrorIs(t, err, ErrUnsupportedLog) @@ -78,20 +74,22 @@ func TestCapabilities(t *testing.T) { require.True(t, ipt.Capabilities().DefaultPolicy) require.True(t, ipt.Capabilities().AddressSets, "iptables manages ipsets") - // CSF is a deliberately minimal backend: no counters, no policy, no sets. + // CSF is a deliberately minimal backend: no counters, no policy. require.False(t, csf.Capabilities().RuleCounters) require.False(t, csf.Capabilities().DefaultPolicy) - require.False(t, csf.Capabilities().AddressSets) // CSF does express (destination) NAT through csf.redirect and per-port // connection limiting through CONNLIMIT. require.True(t, csf.Capabilities().NAT) require.True(t, csf.Capabilities().ConnLimit) + // CSF gains address sets by persisting ipset commands in its pre-hook. + require.True(t, csf.Capabilities().AddressSets) // APF likewise gains NAT (routing files) and connection limiting - // (IG_*_CLIMIT) from its native config, but still no address sets. + // (IG_*_CLIMIT) from its native config, and address sets from ipset commands + // persisted in its pre-hook. require.True(t, apf.Capabilities().NAT) require.True(t, apf.Capabilities().ConnLimit) - require.False(t, apf.Capabilities().AddressSets) + require.True(t, apf.Capabilities().AddressSets) // Both gain logging, rate limiting, connection-state, interface matching and // forward-chain rules by injecting iptables rules through the pre-hook. diff --git a/csf_linux.go b/csf_linux.go index 64bdf0b..7a504fa 100644 --- a/csf_linux.go +++ b/csf_linux.go @@ -111,13 +111,29 @@ func (f *CSF) Type() string { return CSFType } -// hook returns the managed pre-hook script used to inject iptables rules for -// features csf's native config cannot express. -func (f *CSF) hook() *hookScript { - return &hookScript{ - rulePrefix: f.rulePrefix, - hookPath: CSFHook, - hookPerm: 0700, +// Capabilities reports the firewall features csf supports. +func (f *CSF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + ICMPv6: true, + // A csf.conf port list (TCP_IN="80,443,...") stores each port independently + // and reads back as one rule per port, so a discrete multi-port rule does + // not round-trip as a single rule (a range, kept as one token, does). Report + // PortList as unsupported to reflect that; callers open several ports by + // adding a rule per port, which is how csf stores them anyway. + PortList: false, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: false, + DefaultPolicy: false, + RuleCounters: false, + AddressSets: true, + Comments: true, } } @@ -126,87 +142,46 @@ func (f *CSF) GetZone(ctx context.Context, iface string) (zoneName string, err e return "", nil } -// confPortToken renders a port spec for a csf.conf port list, where a range -// is written with a colon (e.g. "30000:35000"). -func (f *CSF) confPortToken(pr PortRange) string { - pr = pr.normalized() - if pr.Start == pr.End { - return strconv.FormatUint(uint64(pr.Start), 10) - } - return fmt.Sprintf("%d:%d", pr.Start, pr.End) -} - -// advPortValue renders port specs for a csf advanced rule, which uses a comma -// list and an underscore range (e.g. "22,80,2000_3000"). -func (f *CSF) advPortValue(specs []PortRange) string { - parts := make([]string, len(specs)) - for i, pr := range specs { - pr = pr.normalized() - 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, ",") -} - -// parseAdvPorts parses a csf advanced-rule port value: a comma list whose -// entries are single ports or underscore ranges (e.g. "22,80,2000_3000"). -func (f *CSF) parseAdvPorts(val string) ([]PortRange, error) { - var specs []PortRange - for _, tok := range strings.Split(val, ",") { - tok = strings.TrimSpace(tok) - if tok == "" { +// ParseConnLimit decodes a csf.conf CONNLIMIT value ("port;limit,...") into +// connection-limit rules: csf caps concurrent new TCP connections per source and +// rejects the excess with a TCP reset, so each entry becomes an inbound tcp +// reject rule carrying a per-source ConnLimit. +func (f *CSF) ParseConnLimit(val string) (rules []*Rule) { + for _, entry := range strings.Split(val, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { continue } - lo, hi, isRange := strings.Cut(tok, "_") - start, err := strconv.ParseUint(strings.TrimSpace(lo), 10, 16) - if err != nil { - return nil, fmt.Errorf("invalid port %q", lo) - } - pr := PortRange{Start: uint16(start), End: uint16(start)} - if isRange { - end, err := strconv.ParseUint(strings.TrimSpace(hi), 10, 16) - if err != nil { - return nil, fmt.Errorf("invalid port %q", hi) - } - pr.End = uint16(end) - if pr.End < pr.Start { - return nil, fmt.Errorf("port range end below start") - } - } - specs = append(specs, pr) - } - if len(specs) == 0 { - return nil, fmt.Errorf("no ports") - } - return specs, nil -} - -// ParsePorts decodes a csf.conf port-list value into one accept rule per port -// token for the given family, protocol, and direction. -func (f *CSF) ParsePorts(val string, family Family, proto Protocol, out bool) (rules []*Rule) { - ports := strings.Split(val, ",") - for _, port := range ports { - port = strings.TrimSpace(port) - if port == "" { + portTok, limitTok, ok := strings.Cut(entry, ";") + if !ok { continue } - - // A csf.conf port token is a single port or a colon range. - pr, err := ParsePortRange(port) + port, err := strconv.ParseUint(strings.TrimSpace(portTok), 10, 16) if err != nil { continue } - rule := &Rule{ - Family: family, - Proto: proto, - Direction: directionFromOutput(out), - Action: Accept, + limit, err := strconv.ParseUint(strings.TrimSpace(limitTok), 10, 32) + if err != nil { + continue } - portSpecsToRule(rule, []PortRange{pr}) - rules = append(rules, rule) + // CONNLIMIT is a single config key, but csf.pl only installs its IPv6 + // CONNLIMIT rule (ip6tables) when csf.conf's IPV6 is enabled (ConfigServer/ + // Config.pm, csf.pl); on the shipped default (IPV6="0") CONNLIMIT is IPv4 + // only. Report FamilyAny when IPv6 handling is on — so a FamilyAny desired + // connlimit rule reconciles with its dual-stack read-back rather than + // churning every Sync — and IPv4 otherwise, matching what csf actually + // enforces. + fam := IPv4 + if f.ipv6Enabled { + fam = FamilyAny + } + rules = append(rules, &Rule{ + Family: fam, + Proto: TCP, + Port: uint16(port), + Action: Reject, + ConnLimit: &ConnLimit{Count: uint(limit), PerSource: true}, + }) } return } @@ -243,62 +218,37 @@ func (f *CSF) parseAddr(v string) (addr string, fam Family, ok bool) { return v, family, true } -// dropActions reads csf.conf's DROP (inbound) and DROP_OUT (outbound) -// settings, which decide whether a csf.deny entry is dropped or rejected: csf -// builds its DENYIN chain with `-j $DROP` and its DENYOUT chain with -// `-j $DROP_OUT`, so a deny rule's effective action follows its direction. -// Only "DROP" and "REJECT" are valid values; anything else (or an unreadable -// file) falls back to stock csf defaults — DROP drops inbound, DROP_OUT rejects -// outbound. -func (f *CSF) dropActions() (dropIn, dropOut Action) { - dropIn, dropOut = Drop, Reject - fd, err := os.Open(CSFConf) - if err != nil { - return - } - defer func() { _ = fd.Close() }() - - scanner := bufio.NewScanner(fd) - for scanner.Scan() { - line := scanner.Text() - if ci := strings.IndexByte(line, '#'); ci >= 0 { - line = line[:ci] - } - key, val, found := strings.Cut(strings.TrimSpace(line), "=") - if !found { +// parseAdvPorts parses a csf advanced-rule port value: a comma list whose +// entries are single ports or underscore ranges (e.g. "22,80,2000_3000"). +func (f *CSF) parseAdvPorts(val string) ([]PortRange, error) { + var specs []PortRange + for _, tok := range strings.Split(val, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { continue } - key = strings.TrimSpace(key) - val = strings.ToUpper(trimQuotes(strings.TrimSpace(val))) - switch key { - case "DROP": - if val == "REJECT" { - dropIn = Reject - } else { - dropIn = Drop + lo, hi, isRange := strings.Cut(tok, "_") + start, err := strconv.ParseUint(strings.TrimSpace(lo), 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid port %q", lo) + } + pr := PortRange{Start: uint16(start), End: uint16(start)} + if isRange { + end, err := strconv.ParseUint(strings.TrimSpace(hi), 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid port %q", hi) } - case "DROP_OUT": - if val == "DROP" { - dropOut = Drop - } else { - dropOut = Reject + pr.End = uint16(end) + if pr.End < pr.Start { + return nil, fmt.Errorf("port range end below start") } } + specs = append(specs, pr) } - return -} - -// denyAction returns the action a csf.deny entry takes in the given direction, -// following csf.conf's DROP (inbound) / DROP_OUT (outbound) settings. A deny -// rule this library writes must carry exactly this action: csf.deny encodes no -// action of its own, so a rule read back is stamped with what csf would apply, -// and a caller asking for the opposite action could never reconcile against it. -func (f *CSF) denyAction(output bool) Action { - dropIn, dropOut := f.dropActions() - if output { - return dropOut + if len(specs) == 0 { + return nil, fmt.Errorf("no ports") } - return dropIn + return specs, nil } // ParseAdvRule decodes a csf advanced allow/deny rule of the form @@ -376,90 +326,6 @@ func (f *CSF) ParseAdvRule(val string, action Action) (r *Rule) { return } -// MarshalAdvRule encodes a rule as a csf advanced allow/deny line. A csf -// advanced rule must carry a source or destination address. -func (f *CSF) MarshalAdvRule(r *Rule) (string, error) { - if r.Proto == ICMPv6 { - return "", fmt.Errorf("csf advanced rules do not support icmpv6") - } - if r.Source == "" && r.Destination == "" { - return "", fmt.Errorf("a csf advanced rule requires a source or destination address") - } - // A csf advanced rule holds a single address field, so a rule matching both a - // source and a destination cannot be expressed; reject it rather than silently - // dropping the destination (installing a broader rule and leaving it - // unremovable). Mirrors the dual-port guard below. - if r.Source != "" && r.Destination != "" { - return "", fmt.Errorf("csf advanced rules cannot match both a source and destination address") - } - // A csf advanced rule carries a single port-flow field, so a rule cannot match - // both a source and a destination port at once. - if r.HasPorts() && r.HasSourcePorts() { - return "", fmt.Errorf("csf advanced rules cannot match both a source and destination port") - } - // An ICMP advanced rule needs a concrete type for the port-flow field: without - // one the address would land there and csf would parse it as `--icmp-type ` - // and drop the rule (see checkICMP). Refuse to emit such a line. - if r.Proto == ICMP && r.ICMPType == nil { - return "", fmt.Errorf("a csf icmp advanced rule requires a concrete icmp type") - } - // A protocol-bearing port rule needs a concrete tcp/udp: an address+port rule - // with ProtocolAny emits a protocol-less advanced line, and csf.pl's linefilter - // defaults that to `-p tcp`, so the rule would silently apply to TCP only while - // the library reads it back as ProtocolAny — leaving UDP open on a deny (or - // unallowed on an accept). The port-only form has no address here (it takes the - // placeholder path, which fans ProtocolAny to tcp+udp); the address form cannot - // fan within a single advanced line, so reject it rather than under-apply it. - if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) { - return "", fmt.Errorf("csf advanced rules require a concrete tcp or udp protocol for a port match with an address: %w", ErrUnsupported) - } - // csf.pl's linefilter reads an advanced line by fixed field position, not by - // tag: it always looks for the port-flow field (d=/s=) before the address - // field. A tcp/udp rule with an address but no port shifts the address into - // the port-flow slot instead, where it is parsed as a garbage --sport/--dport - // value and the address field is left empty — linefilter then requires both - // an address and a port to install anything, so the rule is silently dropped. - if r.Proto != ICMP && !r.HasPorts() && !r.HasSourcePorts() { - return "", fmt.Errorf("a csf tcp/udp advanced rule with an address requires a port: %w", ErrUnsupported) - } - - var parts []string - switch r.Proto { - case TCP: - parts = append(parts, "tcp") - case UDP: - parts = append(parts, "udp") - case ICMP: - parts = append(parts, "icmp") - } - if r.IsOutput() { - parts = append(parts, "out") - } else { - parts = append(parts, "in") - } - - // The port-flow field: an ICMP type, a source port, or a destination port. - switch { - case r.Proto == ICMP: - if r.ICMPType != nil { - parts = append(parts, fmt.Sprintf("d=%d", *r.ICMPType)) - } - case r.HasSourcePorts(): - parts = append(parts, "s="+f.advPortValue(r.SourcePortSpecs())) - case r.HasPorts(): - parts = append(parts, "d="+f.advPortValue(r.PortSpecs())) - } - - // Address. - if r.Source != "" { - parts = append(parts, "s="+r.Source) - } else if r.Destination != "" { - parts = append(parts, "d="+r.Destination) - } - - return strings.Join(parts, "|"), nil -} - // ParseIPList reads a csf.allow/csf.deny file into rules, stamping each with the // given action and any full-line comment that precedes it. func (f *CSF) ParseIPList(filePath string, action Action) (rules []*Rule, err error) { @@ -548,6 +414,128 @@ func (f *CSF) ParseIPList(filePath string, action Action) (rules []*Rule, err er return } +// ParsePorts decodes a csf.conf port-list value into one accept rule per port +// token for the given family, protocol, and direction. +func (f *CSF) ParsePorts(val string, family Family, proto Protocol, out bool) (rules []*Rule) { + ports := strings.Split(val, ",") + for _, port := range ports { + port = strings.TrimSpace(port) + if port == "" { + continue + } + + // A csf.conf port token is a single port or a colon range. + pr, err := ParsePortRange(port) + if err != nil { + continue + } + rule := &Rule{ + Family: family, + Proto: proto, + Direction: directionFromOutput(out), + Action: Accept, + } + portSpecsToRule(rule, []PortRange{pr}) + rules = append(rules, rule) + } + return +} + +// dropActions reads csf.conf's DROP (inbound) and DROP_OUT (outbound) +// settings, which decide whether a csf.deny entry is dropped or rejected: csf +// builds its DENYIN chain with `-j $DROP` and its DENYOUT chain with +// `-j $DROP_OUT`, so a deny rule's effective action follows its direction. +// Only "DROP" and "REJECT" are valid values; anything else (or an unreadable +// file) falls back to stock csf defaults — DROP drops inbound, DROP_OUT rejects +// outbound. +func (f *CSF) dropActions() (dropIn, dropOut Action) { + dropIn, dropOut = Drop, Reject + fd, err := os.Open(CSFConf) + if err != nil { + return + } + defer func() { _ = fd.Close() }() + + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := scanner.Text() + if ci := strings.IndexByte(line, '#'); ci >= 0 { + line = line[:ci] + } + key, val, found := strings.Cut(strings.TrimSpace(line), "=") + if !found { + continue + } + key = strings.TrimSpace(key) + val = strings.ToUpper(trimQuotes(strings.TrimSpace(val))) + switch key { + case "DROP": + if val == "REJECT" { + dropIn = Reject + } else { + dropIn = Drop + } + case "DROP_OUT": + if val == "DROP" { + dropOut = Drop + } else { + dropOut = Reject + } + } + } + return +} + +// hook (see ruleNeedsHook), never csf's own IPV6-gated logic. Every other rule +// that reaches the native csf path (past the hook branch in addRule) with an +// implied IPv6 family is written as a v6-resolving line — whether the family +// comes from a v6 address or from a port-only deny/allow whose "any" address is +// synthesized as ::/0 (portOnlyDenyLines) — so the gate keys on the implied +// family alone. +func (f *CSF) hook() *hookScript { + return &hookScript{ + rulePrefix: f.rulePrefix, + hookPath: CSFHook, + hookPerm: 0700, + } +} + +// mergeProtocols collapses a TCP rule and a UDP rule that are otherwise +// identical into one ProtocolAny rule — the inverse of the tcp+udp fan-out +func (f *CSF) mergeProtocols(rules []*Rule) []*Rule { + for i := 0; i < len(rules); i++ { + if rules[i].Proto != TCP && rules[i].Proto != UDP { + continue + } + for b := i + 1; b < len(rules); b++ { + if rules[b].Proto != TCP && rules[b].Proto != UDP { + continue + } + if rules[i].Proto == rules[b].Proto { + continue + } + // Only collapse a same-family pair: CSF expresses IPv4 and IPv6 through + // separate config keys (TCP_IN vs TCP6_IN), so a tcp/v4 and udp/v6 pair + // cover different families and merging them would drop one family's + // coverage. mergeFamilies has already collapsed genuine v4/v6 twins, so + // the only pairs left to merge here are same-family. + if rules[i].impliedFamily() != rules[b].impliedFamily() { + continue + } + // Compare ignoring protocol: a tcp/udp pair equal in every other field is + // the fanned-out form of one ProtocolAny rule. + a, c := *rules[i], *rules[b] + a.Proto, c.Proto = ProtocolAny, ProtocolAny + if a.EqualBase(&c, true) { + rules[i].Proto = ProtocolAny + rules = append(rules[:b], rules[b+1:]...) + b-- + } + } + } + return rules +} + // GetRules reads all filter rules from csf's config files and the managed // pre-hook, merging family and protocol fan-outs back to their written form. func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { @@ -659,103 +647,52 @@ func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err return } -// mergeProtocols collapses a TCP rule and a UDP rule that are otherwise -// identical into one ProtocolAny rule — the inverse of the tcp+udp fan-out -// EditIPList writes for a ProtocolAny port rule. Without it a ProtocolAny rule -// never reads back as the rule that was written, so it is re-added on every -// reconcile and can never be matched for removal. -func (f *CSF) mergeProtocols(rules []*Rule) []*Rule { - for i := 0; i < len(rules); i++ { - if rules[i].Proto != TCP && rules[i].Proto != UDP { +// confPortToken renders a port spec for a csf.conf port list, where a range +// is written with a colon (e.g. "30000:35000"). +func (f *CSF) confPortToken(pr PortRange) string { + pr = pr.normalized() + if pr.Start == pr.End { + return strconv.FormatUint(uint64(pr.Start), 10) + } + return fmt.Sprintf("%d:%d", pr.Start, pr.End) +} + +// editConnLimit renders the csf.conf CONNLIMIT line with a port's per-source +// limit added or removed, preserving the other entries. +func (f *CSF) editConnLimit(val string, port uint16, limit uint, remove bool) string { + portStr := strconv.Itoa(int(port)) + var kept []string + present := false + for _, tok := range strings.Split(val, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { continue } - for b := i + 1; b < len(rules); b++ { - if rules[b].Proto != TCP && rules[b].Proto != UDP { + p, _, ok := strings.Cut(tok, ";") + if ok && strings.TrimSpace(p) == portStr { + present = true + if remove { continue } - if rules[i].Proto == rules[b].Proto { - continue - } - // Only collapse a same-family pair: CSF expresses IPv4 and IPv6 through - // separate config keys (TCP_IN vs TCP6_IN), so a tcp/v4 and udp/v6 pair - // cover different families and merging them would drop one family's - // coverage. mergeFamilies has already collapsed genuine v4/v6 twins, so - // the only pairs left to merge here are same-family. - if rules[i].impliedFamily() != rules[b].impliedFamily() { - continue - } - // Compare ignoring protocol: a tcp/udp pair equal in every other field is - // the fanned-out form of one ProtocolAny rule. - a, c := *rules[i], *rules[b] - a.Proto, c.Proto = ProtocolAny, ProtocolAny - if a.EqualBase(&c, true) { - rules[i].Proto = ProtocolAny - rules = append(rules[:b], rules[b+1:]...) - b-- - } + kept = append(kept, fmt.Sprintf("%d;%d", port, limit)) + continue } + kept = append(kept, tok) } - return rules + if !remove && !present { + kept = append(kept, fmt.Sprintf("%d;%d", port, limit)) + } + return fmt.Sprintf(`CONNLIMIT = "%s"`, strings.Join(kept, ",")) } -// advMatch reports whether a parsed advanced-rule line (the existing row) -// matches target. A ProtocolAny port rule is written as a separate tcp line and -// udp line, so a ProtocolAny target matches either concrete-protocol line: the -// parsed line's protocol is normalized to match before comparison. The family -// coverage the add and remove paths need is folded into EqualForDedup / -// EqualForRemoval. -func (f *CSF) advMatch(parsed, target *Rule, remove bool) bool { - p := parsed - if target.Proto == ProtocolAny && (parsed.Proto == TCP || parsed.Proto == UDP) { - q := *parsed - q.Proto = ProtocolAny - p = &q - } - if remove { - return p.EqualForRemoval(target, true) - } - return p.EqualForDedup(target, true) -} - -// portOnlyDenyLines returns the advanced csf.deny lines a port-only deny (no -// address) fans out to: one per transport (tcp and udp for a ProtocolAny rule) and -// per family placeholder (0.0.0.0/0 for IPv4, ::/0 for IPv6, both for a -// family-neutral rule). It mirrors the emit path so the add logic can compare -// against the lines already in the file and write only the missing ones — a single -// "exists" flag would skip the whole fan-out when just one family/protocol line was -// already present, leaving the other family/protocol open. -func (f *CSF) portOnlyDenyLines(r *Rule) []string { - dir := "in" - if r.IsOutput() { - dir = "out" - } - port := f.advPortValue(r.PortSpecs()) - protos := []string{r.Proto.String()} - if r.Proto == ProtocolAny { - protos = []string{"tcp", "udp"} - } - var placeholders []string - switch r.impliedFamily() { - case IPv6: - placeholders = []string{"::/0"} - case IPv4: - placeholders = []string{"0.0.0.0/0"} - default: - placeholders = []string{"0.0.0.0/0", "::/0"} - } - var lines []string - for _, ph := range placeholders { - for _, p := range protos { - tokens := []string{p, dir, "d=" + port} - if r.IsOutput() { - tokens = append(tokens, "d="+ph) - } else { - tokens = append(tokens, "s="+ph) - } - lines = append(lines, strings.Join(tokens, "|")) - } - } - return lines +// isConnLimitRule reports whether a rule maps onto csf.conf's CONNLIMIT: a +// per-source cap on concurrent new connections to a single inbound TCP port with +// no address. csf's CONNLIMIT chain rejects the excess with a TCP reset +// (`-j REJECT --reject-with tcp-reset`), so the excess action is Reject, not Drop. +func (f *CSF) isConnLimitRule(r *Rule) bool { + return r.ConnLimit != nil && r.ConnLimit.PerSource && + !r.IsOutput() && r.Proto == TCP && r.Source == "" && r.Destination == "" && + r.HasPorts() && !r.HasPortSet() && r.Action == Reject } // EditRulePort returns the config line for key with the rule's port added or @@ -917,8 +854,168 @@ func (f *CSF) EditConf(ctx context.Context, r *Rule, remove bool) error { return af.Commit() } -// EditIPList rewrites a csf.allow/csf.deny file to add or remove a rule, -// carrying its full-line comment with it. +// advPortValue renders port specs for a csf advanced rule, which uses a comma +// list and an underscore range (e.g. "22,80,2000_3000"). +func (f *CSF) advPortValue(specs []PortRange) string { + parts := make([]string, len(specs)) + for i, pr := range specs { + pr = pr.normalized() + 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, ",") +} + +// MarshalAdvRule encodes a rule as a csf advanced allow/deny line. A csf +// advanced rule must carry a source or destination address. +func (f *CSF) MarshalAdvRule(r *Rule) (string, error) { + if r.Proto == ICMPv6 { + return "", fmt.Errorf("csf advanced rules do not support icmpv6") + } + if r.Source == "" && r.Destination == "" { + return "", fmt.Errorf("a csf advanced rule requires a source or destination address") + } + // A csf advanced rule holds a single address field, so a rule matching both a + // source and a destination cannot be expressed; reject it rather than silently + // dropping the destination (installing a broader rule and leaving it + // unremovable). Mirrors the dual-port guard below. + if r.Source != "" && r.Destination != "" { + return "", fmt.Errorf("csf advanced rules cannot match both a source and destination address") + } + // A csf advanced rule carries a single port-flow field, so a rule cannot match + // both a source and a destination port at once. + if r.HasPorts() && r.HasSourcePorts() { + return "", fmt.Errorf("csf advanced rules cannot match both a source and destination port") + } + // An ICMP advanced rule needs a concrete type for the port-flow field: without + // one the address would land there and csf would parse it as `--icmp-type ` + // and drop the rule (see checkICMP). Refuse to emit such a line. + if r.Proto == ICMP && r.ICMPType == nil { + return "", fmt.Errorf("a csf icmp advanced rule requires a concrete icmp type") + } + // A protocol-bearing port rule needs a concrete tcp/udp: an address+port rule + // with ProtocolAny emits a protocol-less advanced line, and csf.pl's linefilter + // defaults that to `-p tcp`, so the rule would silently apply to TCP only while + // the library reads it back as ProtocolAny — leaving UDP open on a deny (or + // unallowed on an accept). The port-only form has no address here (it takes the + // placeholder path, which fans ProtocolAny to tcp+udp); the address form cannot + // fan within a single advanced line, so reject it rather than under-apply it. + if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) { + return "", fmt.Errorf("csf advanced rules require a concrete tcp or udp protocol for a port match with an address: %w", ErrUnsupported) + } + // csf.pl's linefilter reads an advanced line by fixed field position, not by + // tag: it always looks for the port-flow field (d=/s=) before the address + // field. A tcp/udp rule with an address but no port shifts the address into + // the port-flow slot instead, where it is parsed as a garbage --sport/--dport + // value and the address field is left empty — linefilter then requires both + // an address and a port to install anything, so the rule is silently dropped. + if r.Proto != ICMP && !r.HasPorts() && !r.HasSourcePorts() { + return "", fmt.Errorf("a csf tcp/udp advanced rule with an address requires a port: %w", ErrUnsupported) + } + + var parts []string + switch r.Proto { + case TCP: + parts = append(parts, "tcp") + case UDP: + parts = append(parts, "udp") + case ICMP: + parts = append(parts, "icmp") + } + if r.IsOutput() { + parts = append(parts, "out") + } else { + parts = append(parts, "in") + } + + // The port-flow field: an ICMP type, a source port, or a destination port. + switch { + case r.Proto == ICMP: + if r.ICMPType != nil { + parts = append(parts, fmt.Sprintf("d=%d", *r.ICMPType)) + } + case r.HasSourcePorts(): + parts = append(parts, "s="+f.advPortValue(r.SourcePortSpecs())) + case r.HasPorts(): + parts = append(parts, "d="+f.advPortValue(r.PortSpecs())) + } + + // Address. + if r.Source != "" { + parts = append(parts, "s="+r.Source) + } else if r.Destination != "" { + parts = append(parts, "d="+r.Destination) + } + + return strings.Join(parts, "|"), nil +} + +// advMatch reports whether a parsed advanced-rule line (the existing row) +// matches target. A ProtocolAny port rule is written as a separate tcp line and +// udp line, so a ProtocolAny target matches either concrete-protocol line: the +// parsed line's protocol is normalized to match before comparison. The family +// coverage the add and remove paths need is folded into EqualForDedup / +// EqualForRemoval. +func (f *CSF) advMatch(parsed, target *Rule, remove bool) bool { + p := parsed + if target.Proto == ProtocolAny && (parsed.Proto == TCP || parsed.Proto == UDP) { + q := *parsed + q.Proto = ProtocolAny + p = &q + } + if remove { + return p.EqualForRemoval(target, true) + } + return p.EqualForDedup(target, true) +} + +// portOnlyDenyLines returns the advanced csf.deny lines a port-only deny (no +// address) fans out to: one per transport (tcp and udp for a ProtocolAny rule) and +// per family placeholder (0.0.0.0/0 for IPv4, ::/0 for IPv6, both for a +// family-neutral rule). It mirrors the emit path so the add logic can compare +// against the lines already in the file and write only the missing ones — a single +// "exists" flag would skip the whole fan-out when just one family/protocol line was +// already present, leaving the other family/protocol open. +func (f *CSF) portOnlyDenyLines(r *Rule) []string { + dir := "in" + if r.IsOutput() { + dir = "out" + } + port := f.advPortValue(r.PortSpecs()) + protos := []string{r.Proto.String()} + if r.Proto == ProtocolAny { + protos = []string{"tcp", "udp"} + } + var placeholders []string + switch r.impliedFamily() { + case IPv6: + placeholders = []string{"::/0"} + case IPv4: + placeholders = []string{"0.0.0.0/0"} + default: + placeholders = []string{"0.0.0.0/0", "::/0"} + } + var lines []string + for _, ph := range placeholders { + for _, p := range protos { + tokens := []string{p, dir, "d=" + port} + if r.IsOutput() { + tokens = append(tokens, "d="+ph) + } else { + tokens = append(tokens, "s="+ph) + } + lines = append(lines, strings.Join(tokens, "|")) + } + } + return lines +} + +// EditIPList writes for a ProtocolAny port rule. Without it a ProtocolAny rule +// never reads back as the rule that was written, so it is re-added on every +// reconcile and can never be matched for removal. func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r *Rule, remove bool) error { // Read the allow/deny IP rule list. fd, err := os.Open(filePath) @@ -1137,16 +1234,6 @@ func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r return af.Commit() } -// isConnLimitRule reports whether a rule maps onto csf.conf's CONNLIMIT: a -// per-source cap on concurrent new connections to a single inbound TCP port with -// no address. csf's CONNLIMIT chain rejects the excess with a TCP reset -// (`-j REJECT --reject-with tcp-reset`), so the excess action is Reject, not Drop. -func (f *CSF) isConnLimitRule(r *Rule) bool { - return r.ConnLimit != nil && r.ConnLimit.PerSource && - !r.IsOutput() && r.Proto == TCP && r.Source == "" && r.Destination == "" && - r.HasPorts() && !r.HasPortSet() && r.Action == Reject -} - // checkConnLimit rejects a connection-limit request csf's CONNLIMIT cannot // express, so an inexpressible one is reported rather than silently dropped. func (f *CSF) checkConnLimit(r *Rule) error { @@ -1156,105 +1243,6 @@ func (f *CSF) checkConnLimit(r *Rule) error { return fmt.Errorf("csf connection limiting (CONNLIMIT) applies only to a single inbound tcp port with no address, per source, rejecting the excess with a tcp reset: %w", ErrUnsupportedConnLimit) } -// ParseConnLimit decodes a csf.conf CONNLIMIT value ("port;limit,...") into -// connection-limit rules: csf caps concurrent new TCP connections per source and -// rejects the excess with a TCP reset, so each entry becomes an inbound tcp -// reject rule carrying a per-source ConnLimit. -func (f *CSF) ParseConnLimit(val string) (rules []*Rule) { - for _, entry := range strings.Split(val, ",") { - entry = strings.TrimSpace(entry) - if entry == "" { - continue - } - portTok, limitTok, ok := strings.Cut(entry, ";") - if !ok { - continue - } - port, err := strconv.ParseUint(strings.TrimSpace(portTok), 10, 16) - if err != nil { - continue - } - limit, err := strconv.ParseUint(strings.TrimSpace(limitTok), 10, 32) - if err != nil { - continue - } - // CONNLIMIT is a single config key, but csf.pl only installs its IPv6 - // CONNLIMIT rule (ip6tables) when csf.conf's IPV6 is enabled (ConfigServer/ - // Config.pm, csf.pl); on the shipped default (IPV6="0") CONNLIMIT is IPv4 - // only. Report FamilyAny when IPv6 handling is on — so a FamilyAny desired - // connlimit rule reconciles with its dual-stack read-back rather than - // churning every Sync — and IPv4 otherwise, matching what csf actually - // enforces. - fam := IPv4 - if f.ipv6Enabled { - fam = FamilyAny - } - rules = append(rules, &Rule{ - Family: fam, - Proto: TCP, - Port: uint16(port), - Action: Reject, - ConnLimit: &ConnLimit{Count: uint(limit), PerSource: true}, - }) - } - return -} - -// editConnLimit renders the csf.conf CONNLIMIT line with a port's per-source -// limit added or removed, preserving the other entries. -func (f *CSF) editConnLimit(val string, port uint16, limit uint, remove bool) string { - portStr := strconv.Itoa(int(port)) - var kept []string - present := false - for _, tok := range strings.Split(val, ",") { - tok = strings.TrimSpace(tok) - if tok == "" { - continue - } - p, _, ok := strings.Cut(tok, ";") - if ok && strings.TrimSpace(p) == portStr { - present = true - if remove { - continue - } - kept = append(kept, fmt.Sprintf("%d;%d", port, limit)) - continue - } - kept = append(kept, tok) - } - if !remove && !present { - kept = append(kept, fmt.Sprintf("%d;%d", port, limit)) - } - return fmt.Sprintf(`CONNLIMIT = "%s"`, strings.Join(kept, ",")) -} - -// checkSourcePort rejects a source-port match csf cannot express. csf source -// ports live in an advanced rule, which requires an address, so a source-port -// rule without one has nowhere to go. -func (f *CSF) checkSourcePort(r *Rule) error { - if r.HasSourcePorts() && r.Source == "" && r.Destination == "" { - return fmt.Errorf("a csf source-port rule requires a source or destination address: %w", ErrUnsupportedSourcePort) - } - return nil -} - -// checkPortProto rejects a port match on a concrete protocol csf cannot -// express as a port. csf's port lists are TCP_IN/UDP_IN only, so a port on a -// concrete non-tcp/udp protocol (e.g. sctp) would otherwise be wrongly written -// into BOTH the TCP and UDP lists. ProtocolAny is allowed: an address-less -// accept maps to both lists (the faithful "any" expansion) and a port-only -// reject to a protocol-less csf.deny advanced rule. -func (f *CSF) checkPortProto(r *Rule) error { - switch r.Proto { - case TCP, UDP, ProtocolAny: - return nil - } - if r.HasPorts() || r.HasSourcePorts() { - return fmt.Errorf("csf requires a tcp or udp protocol for a port match: %w", ErrUnsupported) - } - return nil -} - // checkICMP rejects ICMP rules csf cannot express: csf advanced rules are // built on iptables ICMP (IPv4) and require an address, and must also carry a // concrete type: csf's linefilter treats the single port-flow field as the @@ -1276,26 +1264,54 @@ func (f *CSF) checkICMP(r *Rule) error { return nil } +// checkPortProto rejects a port match on a concrete protocol csf cannot +// express as a port. csf's port lists are TCP_IN/UDP_IN only, so a port on a +// concrete non-tcp/udp protocol (e.g. sctp) would otherwise be wrongly written +// into BOTH the TCP and UDP lists. ProtocolAny is allowed: an address-less +// accept maps to both lists (the faithful "any" expansion) and a port-only +// reject to a protocol-less csf.deny advanced rule. +func (f *CSF) checkPortProto(r *Rule) error { + switch r.Proto { + case TCP, UDP, ProtocolAny: + return nil + } + if r.HasPorts() || r.HasSourcePorts() { + return fmt.Errorf("csf requires a tcp or udp protocol for a port match: %w", ErrUnsupported) + } + return nil +} + +// checkSourcePort rejects a source-port match csf cannot express. csf source +// ports live in an advanced rule, which requires an address, so a source-port +// rule without one has nowhere to go. +func (f *CSF) checkSourcePort(r *Rule) error { + if r.HasSourcePorts() && r.Source == "" && r.Destination == "" { + return fmt.Errorf("a csf source-port rule requires a source or destination address: %w", ErrUnsupportedSourcePort) + } + return nil +} + +// denyAction returns the action a csf.deny entry takes in the given direction, +// following csf.conf's DROP (inbound) / DROP_OUT (outbound) settings. A deny +// rule this library writes must carry exactly this action: csf.deny encodes no +// action of its own, so a rule read back is stamped with what csf would apply, +// and a caller asking for the opposite action could never reconcile against it. +func (f *CSF) denyAction(output bool) Action { + dropIn, dropOut := f.dropActions() + if output { + return dropOut + } + return dropIn +} + // ipv6Unavailable reports whether adding r would silently write a // csf.allow/csf.deny line (plain or advanced) that csf.pl's linefilter drops: // any line resolving to an IPv6 address is dropped whenever csf.conf's IPV6 is // not "1". ICMPv6 is unaffected — it always routes through the raw-iptables -// hook (see ruleNeedsHook), never csf's own IPV6-gated logic. Every other rule -// that reaches the native csf path (past the hook branch in addRule) with an -// implied IPv6 family is written as a v6-resolving line — whether the family -// comes from a v6 address or from a port-only deny/allow whose "any" address is -// synthesized as ::/0 (portOnlyDenyLines) — so the gate keys on the implied -// family alone. func (f *CSF) ipv6Unavailable(r *Rule) bool { return !f.ipv6Enabled && r.Proto != ICMPv6 && r.impliedFamily() == IPv6 } -// AddRule adds a filter rule to the appropriate csf construct: a csf.conf port -// list, an advanced rule, a bare address list, CONNLIMIT, or the pre-hook. -func (f *CSF) AddRule(ctx context.Context, zoneName string, r *Rule) error { - return f.addRule(ctx, zoneName, r, true) -} - // addRule is AddRule's implementation, with the IPv6 gate optional so Restore // can reproduce a prior snapshot's inert entries rather than be rejected by a // gate meant to catch fresh no-op writes. @@ -1401,6 +1417,61 @@ func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6 return nil } +// AddRule adds a filter rule to the appropriate csf construct: a csf.conf port +// list, an advanced rule, a bare address list, CONNLIMIT, or the pre-hook. +func (f *CSF) AddRule(ctx context.Context, zoneName string, r *Rule) error { + return f.addRule(ctx, zoneName, r, true) +} + +// InsertRule is unsupported: CSF organizes rules in config files, not an ordered list. +func (f *CSF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + return unsupportedOrdering(f.Type()) +} + +// MoveRule is unsupported for the same reason as InsertRule. +func (f *CSF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + return unsupportedOrdering(f.Type()) +} + +// removePlainHost drops the bidirectional plain csf.allow/csf.deny line backing the +// DirAny rule e, choosing the list by the rule's action. +func (f *CSF) removePlainHost(ctx context.Context, e *Rule) error { + if e.Action == Accept { + return f.EditIPList(ctx, CSFAllow, Accept, e, true) + } + return f.EditIPList(ctx, CSFDeny, f.denyAction(false), e, true) +} + +// removeBareHostOneWay removes a one-way bare-address host rule. Such a rule is +// stored either as its own hook rule or as one direction of a bidirectional plain +// csf.allow/csf.deny line (a DirAny rule). When a matching plain line exists, split +// it: drop the line and re-add the surviving opposite direction as a hook rule so +// the untargeted direction keeps its coverage. +func (f *CSF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule) error { + existing, err := f.GetRules(ctx, zoneName) + if err != nil { + return err + } + for _, e := range existing { + if !e.IsAny() || !e.EqualForRemoval(r, true) { + continue + } + // The host is stored as a bidirectional plain line; drop it, then re-add the + // surviving direction as a hook rule. + if err := f.removePlainHost(ctx, e); err != nil { + return err + } + if s := splitDualRowDirection(e, r); s != nil { + _, err := f.hook().edit(s, false) + return err + } + return nil + } + // Not stored as a plain line; remove the one-way hook rule. + _, err = f.hook().edit(r, true) + return err +} + // RemoveRule removes a filter rule from whichever csf construct holds it. func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { // A non-plain-line DirAny target fans out into its two concrete-direction rules, @@ -1499,213 +1570,6 @@ func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { return nil } -// removeBareHostOneWay removes a one-way bare-address host rule. Such a rule is -// stored either as its own hook rule or as one direction of a bidirectional plain -// csf.allow/csf.deny line (a DirAny rule). When a matching plain line exists, split -// it: drop the line and re-add the surviving opposite direction as a hook rule so -// the untargeted direction keeps its coverage. -func (f *CSF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule) error { - existing, err := f.GetRules(ctx, zoneName) - if err != nil { - return err - } - for _, e := range existing { - if !e.IsAny() || !e.EqualForRemoval(r, true) { - continue - } - // The host is stored as a bidirectional plain line; drop it, then re-add the - // surviving direction as a hook rule. - if err := f.removePlainHost(ctx, e); err != nil { - return err - } - if s := splitDualRowDirection(e, r); s != nil { - _, err := f.hook().edit(s, false) - return err - } - return nil - } - // Not stored as a plain line; remove the one-way hook rule. - _, err = f.hook().edit(r, true) - return err -} - -// removePlainHost drops the bidirectional plain csf.allow/csf.deny line backing the -// DirAny rule e, choosing the list by the rule's action. -func (f *CSF) removePlainHost(ctx context.Context, e *Rule) error { - if e.Action == Accept { - return f.EditIPList(ctx, CSFAllow, Accept, e, true) - } - return f.EditIPList(ctx, CSFDeny, f.denyAction(false), e, true) -} - -// Reload restarts csf to apply config changes, retrying past csf's transient -// restart lock. -func (f *CSF) Reload(ctx context.Context) error { - // csf serializes restarts behind a lock, so a reload issued while a previous - // restart is still finishing fails transiently with "csf is being restarted, try - // again in a moment" (Resource temporarily unavailable). Wait and retry rather - // than surfacing that transient condition — the caller asked for a reload, not to - // race csf's own in-flight restart. - var err error - for attempt := 0; attempt < 20; attempt++ { - if _, err = runCommand(ctx, "csf", "-r"); err == nil { - return nil - } - if !strings.Contains(err.Error(), "being restarted") && !strings.Contains(err.Error(), "temporarily unavailable") { - return err - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(500 * time.Millisecond): - } - } - return err -} - -// Close releases any resources held by the manager; csf holds none. -func (f *CSF) Close(ctx context.Context) error { - return nil -} - -// InsertRule is unsupported: CSF organizes rules in config files, not an ordered list. -func (f *CSF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { - return unsupportedOrdering(f.Type()) -} - -// InsertNATRule is unsupported: CSF stores redirects in a config file it applies -// as a whole, with no explicit ordering. -func (f *CSF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { - return unsupportedOrdering(f.Type()) -} - -// MoveRule is unsupported for the same reason as InsertRule. -func (f *CSF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { - return unsupportedOrdering(f.Type()) -} - -// Backup captures the current filter and NAT rules managed by this backend. -func (f *CSF) 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 full filter and NAT rule state; Restore removes the - // current rules and re-adds these, so every rule read is preserved. - return &Backup{Rules: rules, NATRules: natRules}, nil -} - -// Restore replaces the managed rules with the contents of a Backup. -func (f *CSF) Restore(ctx context.Context, zoneName string, backup *Backup) error { - if backup == nil { - return fmt.Errorf("backup cannot be nil") - } - - // Remove existing rules. - existing, err := f.GetRules(ctx, zoneName) - if err != nil { - return err - } - for _, r := range existing { - if err := f.RemoveRule(ctx, zoneName, r); err != nil { - return err - } - } - existingNAT, err := f.GetNATRules(ctx, zoneName) - if err != nil { - return err - } - for _, r := range existingNAT { - if err := f.RemoveNATRule(ctx, zoneName, r); err != nil { - return err - } - } - - // Re-add rules from backup. - for _, r := range backup.Rules { - if err := f.addRule(ctx, zoneName, r, false); err != nil { - return err - } - } - for _, r := range backup.NATRules { - if err := f.AddNATRule(ctx, zoneName, r); err != nil { - return err - } - } - return nil -} - -// redirectPort renders a single port for a csf.redirect field, using "*" for -// an unset (0) port, which csf reads as "any/unchanged". -func (f *CSF) redirectPort(p uint16) string { - if p == 0 { - return "*" - } - return strconv.FormatUint(uint64(p), 10) -} - -// redirectAddr renders an address for a csf.redirect field, using "*" for an -// empty (any) address. -func (f *CSF) redirectAddr(a string) string { - if a == "" { - return "*" - } - return a -} - -// MarshalNATRule encodes a NAT rule as a csf.redirect line -// ("IPx|portA|IPy|portB|proto"). csf.redirect expresses only destination NAT: a -// Redirect to a local port (IPy = "*") or a DNAT forward to another host -// (IPy = ToAddress). Source NAT, port ranges/lists, and non-tcp/udp protocols -// are not representable. -func (f *CSF) MarshalNATRule(r *NATRule) (string, error) { - if err := r.validate(); err != nil { - return "", err - } - if r.Kind.isSource() { - return "", fmt.Errorf("csf.redirect cannot express source NAT: %w", ErrUnsupportedNAT) - } - if r.Proto != TCP && r.Proto != UDP { - return "", fmt.Errorf("csf.redirect requires a tcp or udp protocol") - } - if r.HasPortSet() { - return "", fmt.Errorf("csf.redirect matches a single port, not a range or list") - } - if r.Source != "" { - return "", fmt.Errorf("csf.redirect cannot match a source address") - } - - ipx := f.redirectAddr(r.Destination) - porta := f.redirectPort(r.Port) - switch r.Kind { - case Redirect: - // A local port redirect: IPy is "*", portB is the target local port. - if r.Port == 0 { - return "", fmt.Errorf("a csf redirect requires a matched port") - } - return strings.Join([]string{ipx, porta, "*", f.redirectPort(r.ToPort), r.Proto.String()}, "|"), nil - case DNAT: - // A forward to another host: IPy is the translation address. csf.redirect - // accepts a DNAT only in two shapes (csf.pl "Invalid csf.redirect format" - // otherwise): a full-IP forward (concrete IPx, both ports "*") or a port - // forward (concrete IPx and both ports concrete). Reject the shapes csf - // would refuse rather than emit a line that aborts the whole redirect load — - // the line still parses back here, so a round-trip check alone misses it. - if r.Destination == "" { - return "", fmt.Errorf("csf.redirect requires a destination address for a forward: %w", ErrUnsupportedNAT) - } - if (r.Port == 0) != (r.ToPort == 0) { - return "", fmt.Errorf("csf.redirect forward requires both a matched and a target port, or neither: %w", ErrUnsupportedNAT) - } - return strings.Join([]string{ipx, porta, r.ToAddress, f.redirectPort(r.ToPort), r.Proto.String()}, "|"), nil - } - return "", fmt.Errorf("csf.redirect cannot express this nat kind: %w", ErrUnsupportedNAT) -} - // UnmarshalNATRule decodes a csf.redirect line into a NATRule. func (f *CSF) UnmarshalNATRule(line string) *NATRule { fields := strings.Split(line, "|") @@ -1803,6 +1667,73 @@ func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, err return merged, nil } +// redirectAddr renders an address for a csf.redirect field, using "*" for an +// empty (any) address. +func (f *CSF) redirectAddr(a string) string { + if a == "" { + return "*" + } + return a +} + +// redirectPort renders a single port for a csf.redirect field, using "*" for +// an unset (0) port, which csf reads as "any/unchanged". +func (f *CSF) redirectPort(p uint16) string { + if p == 0 { + return "*" + } + return strconv.FormatUint(uint64(p), 10) +} + +// MarshalNATRule encodes a NAT rule as a csf.redirect line +// ("IPx|portA|IPy|portB|proto"). csf.redirect expresses only destination NAT: a +// Redirect to a local port (IPy = "*") or a DNAT forward to another host +// (IPy = ToAddress). Source NAT, port ranges/lists, and non-tcp/udp protocols +// are not representable. +func (f *CSF) MarshalNATRule(r *NATRule) (string, error) { + if err := r.validate(); err != nil { + return "", err + } + if r.Kind.isSource() { + return "", fmt.Errorf("csf.redirect cannot express source NAT: %w", ErrUnsupportedNAT) + } + if r.Proto != TCP && r.Proto != UDP { + return "", fmt.Errorf("csf.redirect requires a tcp or udp protocol") + } + if r.HasPortSet() { + return "", fmt.Errorf("csf.redirect matches a single port, not a range or list") + } + if r.Source != "" { + return "", fmt.Errorf("csf.redirect cannot match a source address") + } + + ipx := f.redirectAddr(r.Destination) + porta := f.redirectPort(r.Port) + switch r.Kind { + case Redirect: + // A local port redirect: IPy is "*", portB is the target local port. + if r.Port == 0 { + return "", fmt.Errorf("a csf redirect requires a matched port") + } + return strings.Join([]string{ipx, porta, "*", f.redirectPort(r.ToPort), r.Proto.String()}, "|"), nil + case DNAT: + // A forward to another host: IPy is the translation address. csf.redirect + // accepts a DNAT only in two shapes (csf.pl "Invalid csf.redirect format" + // otherwise): a full-IP forward (concrete IPx, both ports "*") or a port + // forward (concrete IPx and both ports concrete). Reject the shapes csf + // would refuse rather than emit a line that aborts the whole redirect load — + // the line still parses back here, so a round-trip check alone misses it. + if r.Destination == "" { + return "", fmt.Errorf("csf.redirect requires a destination address for a forward: %w", ErrUnsupportedNAT) + } + if (r.Port == 0) != (r.ToPort == 0) { + return "", fmt.Errorf("csf.redirect forward requires both a matched and a target port, or neither: %w", ErrUnsupportedNAT) + } + return strings.Join([]string{ipx, porta, r.ToAddress, f.redirectPort(r.ToPort), r.Proto.String()}, "|"), nil + } + return "", fmt.Errorf("csf.redirect cannot express this nat kind: %w", ErrUnsupportedNAT) +} + // editRedirect adds or removes a csf.redirect line, returning without change when // an add is a duplicate or a remove finds no match. func (f *CSF) editRedirect(r *NATRule, remove bool) error { @@ -1875,35 +1806,82 @@ func (f *CSF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error return f.editRedirect(r, false) } +// InsertNATRule is unsupported: CSF stores redirects in a config file it applies +// as a whole, with no explicit ordering. +func (f *CSF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + return unsupportedOrdering(f.Type()) +} + // RemoveNATRule removes a NAT rule from csf.redirect. func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { return f.editRedirect(r, true) } -// Capabilities reports the firewall features csf supports. -func (f *CSF) Capabilities() Capabilities { - return Capabilities{ - Output: true, - Forward: true, - ICMPv6: true, - // A csf.conf port list (TCP_IN="80,443,...") stores each port independently - // and reads back as one rule per port, so a discrete multi-port rule does - // not round-trip as a single rule (a range, kept as one token, does). Report - // PortList as unsupported to reflect that; callers open several ports by - // adding a rule per port, which is how csf stores them anyway. - PortList: false, - ConnState: true, - InterfaceMatch: true, - Logging: true, - RateLimit: true, - ConnLimit: true, - NAT: true, - RuleOrdering: false, - DefaultPolicy: false, - RuleCounters: false, - AddressSets: false, - Comments: true, +// Backup captures the current filter and NAT rules managed by this backend. +func (f *CSF) 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 full filter and NAT rule state plus the hook's address + // sets; Restore removes the current rules and re-adds these, so every rule read + // is preserved. + backup := &Backup{Rules: rules, NATRules: natRules} + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *CSF) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + + // Remove existing rules. + existing, err := f.GetRules(ctx, zoneName) + if err != nil { + return err + } + for _, r := range existing { + if err := f.RemoveRule(ctx, zoneName, r); err != nil { + return err + } + } + existingNAT, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return err + } + for _, r := range existingNAT { + if err := f.RemoveNATRule(ctx, zoneName, r); err != nil { + return err + } + } + + // Recreate the address sets before the rules so a set-referencing rule resolves + // when csf sources the hook. The old rules are already gone, and editAddressSet + // rewrites each set's block idempotently, so cleanFirst is unnecessary. + if err := restoreBackupSets(ctx, f, backup, false); err != nil { + return err + } + + // Re-add rules from backup. + for _, r := range backup.Rules { + if err := f.addRule(ctx, zoneName, r, false); err != nil { + return err + } + } + for _, r := range backup.NATRules { + if err := f.AddNATRule(ctx, zoneName, r); err != nil { + return err + } + } + return nil } // GetDefaultPolicy is unsupported: csf exposes no chain default policy. @@ -1916,32 +1894,80 @@ func (f *CSF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *Def return unsupportedPolicy(f.Type()) } -// GetAddressSets is unsupported: csf has no address-set construct. +// GetAddressSets returns the address sets carried by the csf pre-hook. func (f *CSF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { - return nil, unsupportedSet(f.Type()) + return f.hook().getAddressSets() } -// GetAddressSet is unsupported: csf has no address-set construct. +// GetAddressSet returns a single address set by name, or an error if absent. func (f *CSF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { - return nil, unsupportedSet(f.Type()) + sets, err := f.hook().getAddressSets() + if err != nil { + return nil, err + } + for _, s := range sets { + if s.Name == name { + return s, nil + } + } + return nil, fmt.Errorf("address set %q not found", name) } -// AddAddressSet is unsupported: csf has no address-set construct. +// AddAddressSet writes a set as ipset commands in the pre-hook; csf -r (Reload) +// sources the hook to create the set. Re-adding a set is idempotent. func (f *CSF) AddAddressSet(ctx context.Context, set *AddressSet) error { - return unsupportedSet(f.Type()) + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + _, err := f.hook().editAddressSet(set, false) + return err } -// RemoveAddressSet is unsupported: csf has no address-set construct. +// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a +// hook rule still references the set. func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error { - return unsupportedSet(f.Type()) + _, err := f.hook().editAddressSet(&AddressSet{Name: name}, true) + return err } -// AddAddressSetEntry is unsupported: csf has no address-set construct. +// AddAddressSetEntry adds an entry to an existing set in the pre-hook. func (f *CSF) AddAddressSetEntry(ctx context.Context, name, entry string) error { - return unsupportedSet(f.Type()) + _, err := f.hook().editAddressSetEntry(name, entry, false) + return err } -// RemoveAddressSetEntry is unsupported: csf has no address-set construct. +// RemoveAddressSetEntry removes an entry from an existing set in the pre-hook. func (f *CSF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { - return unsupportedSet(f.Type()) + _, err := f.hook().editAddressSetEntry(name, entry, true) + return err +} + +// Reload restarts csf to apply config changes, retrying past csf's transient +// restart lock. +func (f *CSF) Reload(ctx context.Context) error { + // csf serializes restarts behind a lock, so a reload issued while a previous + // restart is still finishing fails transiently with "csf is being restarted, try + // again in a moment" (Resource temporarily unavailable). Wait and retry rather + // than surfacing that transient condition — the caller asked for a reload, not to + // race csf's own in-flight restart. + var err error + for attempt := 0; attempt < 20; attempt++ { + if _, err = runCommand(ctx, "csf", "-r"); err == nil { + return nil + } + if !strings.Contains(err.Error(), "being restarted") && !strings.Contains(err.Error(), "temporarily unavailable") { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(500 * time.Millisecond): + } + } + return err +} + +// Close releases any resources held by the manager; csf holds none. +func (f *CSF) Close(ctx context.Context) error { + return nil } diff --git a/firewall.go b/firewall.go index 507022b..f3c4cee 100644 --- a/firewall.go +++ b/firewall.go @@ -2056,21 +2056,6 @@ type Manager interface { // RemoveNATRule removes a NAT rule from the zone. RemoveNATRule(ctx context.Context, zoneName string, rule *NATRule) error - // Backup captures the current filter and NAT rules the manager reports, plus — - // on backends that advertise them — the default policy and the managed address - // sets. On container backends (nftables table, pf anchor, firewalld zone) this - // is scoped to the library's container by construction; on tag/comment backends - // it is the whole chain, foreign rules included. It does not filter on the - // HasPrefix flag. - Backup(ctx context.Context, zoneName string) (*Backup, error) - - // Restore reconciles the firewall to the contents of a Backup. The captured - // address sets are recreated first (so a set-referencing rule resolves), then - // existing filter and NAT rules the backend acts on are removed and the backup - // rules added, and finally the captured default policy is re-asserted. Like Sync - // it reconciles the actual state and does not filter on HasPrefix. - Restore(ctx context.Context, zoneName string, backup *Backup) error - // GetDefaultPolicy returns the default action applied to packets that match // no rule. A direction the backend cannot express is returned as // ActionInvalid. Backends that cannot manage a default policy at all return @@ -2107,6 +2092,21 @@ type Manager interface { // cannot manage address sets return an unsupported error. RemoveAddressSetEntry(ctx context.Context, name, entry string) error + // Backup captures the current filter and NAT rules the manager reports, plus — + // on backends that advertise them — the default policy and the managed address + // sets. On container backends (nftables table, pf anchor, firewalld zone) this + // is scoped to the library's container by construction; on tag/comment backends + // it is the whole chain, foreign rules included. It does not filter on the + // HasPrefix flag. + Backup(ctx context.Context, zoneName string) (*Backup, error) + + // Restore reconciles the firewall to the contents of a Backup. The captured + // address sets are recreated first (so a set-referencing rule resolves), then + // existing filter and NAT rules the backend acts on are removed and the backup + // rules added, and finally the captured default policy is re-asserted. Like Sync + // it reconciles the actual state and does not filter on HasPrefix. + Restore(ctx context.Context, zoneName string, backup *Backup) error + // Reload reloads the manager to activate new rules. Reload(ctx context.Context) error diff --git a/firewalld_linux.go b/firewalld_linux.go index 97861b0..125f6ba 100644 --- a/firewalld_linux.go +++ b/firewalld_linux.go @@ -14,34 +14,6 @@ import ( // FirewallDType is the backend identifier reported by FirewallD.Type. const FirewallDType = "firewalld" -// ignoreAlreadyEnabled treats firewalld's ALREADY_ENABLED as success, making an -// add idempotent: re-adding an element that is already present is a no-op. -func (f *FirewallD) ignoreAlreadyEnabled(err error) error { - if errors.Is(err, firewalld.ErrAlreadyEnabled) { - return nil - } - return err -} - -// ignoreNotEnabled treats firewalld's NOT_ENABLED as success, making a remove -// idempotent: removing an element that is not present is a no-op. -func (f *FirewallD) ignoreNotEnabled(err error) error { - if errors.Is(err, firewalld.ErrNotEnabled) { - return nil - } - return err -} - -// FirewallD manages a firewalld instance over D-Bus, mapping the Manager -// interface onto firewalld's zones, rich rules, and ipsets. -type FirewallD struct { - Conn *firewalld.Conn - // rulePrefix is accepted for a consistent constructor signature across - // backends. firewalld organizes rules into zones rather than a private - // namespace, so the prefix is not applied to individual rules. - rulePrefix string -} - // NewFirewallD connects to firewalld and returns a manager, or an error when // firewalld cannot be reached. func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) { @@ -63,6 +35,27 @@ func (f *FirewallD) Type() string { return FirewallDType } +// Capabilities reports which optional features this backend supports. +func (f *FirewallD) Capabilities() Capabilities { + return Capabilities{ + Output: false, + Zones: true, + Priority: true, + ICMPv6: true, + PortList: false, + ConnState: false, + InterfaceMatch: false, + Logging: true, + RateLimit: true, + ConnLimit: false, + NAT: true, + RuleOrdering: false, + DefaultPolicy: true, + RuleCounters: false, + AddressSets: true, + } +} + // GetZone returns the firewalld zone bound to the interface, falling back to the // default zone when the interface is unbound. func (f *FirewallD) GetZone(ctx context.Context, iface string) (zoneName string, err error) { @@ -87,15 +80,19 @@ func (f *FirewallD) GetZone(ctx context.Context, iface string) (zoneName string, return "", fmt.Errorf("unable to find zone") } -// resolveZoneName substitutes the default zone when zoneName is empty. The rest -// of go-firewall treats an empty zone as "the default" (zoneless backends ignore -// it entirely), but firewalld's permanent config interface rejects an empty zone -// name with INVALID_ZONE, so every zone-scoped method resolves it here first. -func (f *FirewallD) resolveZoneName(ctx context.Context, zoneName string) (string, error) { - if zoneName != "" { - return zoneName, nil +// icmpTypeTable selects the IPv4 or IPv6 name/number table by family. +func (f *FirewallD) icmpTypeTable(isV6 bool) map[string]uint8 { + if isV6 { + return fwICMPv6Types } - return f.Conn.DefaultZone(ctx) + return fwICMPv4Types +} + +// icmpTypeNumber returns the numeric ICMP type for a firewalld icmp-type name in +// the given family, and whether the name is known. +func (f *FirewallD) icmpTypeNumber(isV6 bool, name string) (uint8, bool) { + n, ok := f.icmpTypeTable(isV6)[strings.ToLower(name)] + return n, ok } // splitRichRuleFields tokenizes a firewalld rich rule on whitespace while @@ -427,6 +424,131 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) { return } +// resolveZoneName substitutes the default zone when zoneName is empty. The rest +// of go-firewall treats an empty zone as "the default" (zoneless backends ignore +// it entirely), but firewalld's permanent config interface rejects an empty zone +// name with INVALID_ZONE, so every zone-scoped method resolves it here first. +func (f *FirewallD) resolveZoneName(ctx context.Context, zoneName string) (string, error) { + if zoneName != "" { + return zoneName, nil + } + return f.Conn.DefaultZone(ctx) +} + +// zonePortRules maps a firewalld zone port list (settings.Ports or SourcePorts) +// to allow rules, one per entry. source selects whether the range binds to the +// source-port or destination-port fields. A port on an unmodeled protocol (e.g. +// dccp, which GetProtocol maps to ProtocolAny) is skipped: it has no expressible +// Rule, so surfacing it would leave a rule RemoveRule and MarshalRichRule reject. +// This mirrors the protocols loop's guard. +func (f *FirewallD) zonePortRules(ports []firewalld.Port, source bool) []*Rule { + var rules []*Rule + for _, port := range ports { + pr, perr := ParsePortRange(port.Port) + if perr != nil { + continue + } + proto := GetProtocol(port.Protocol) + if !proto.HasPorts() { + continue + } + rule := &Rule{Proto: proto, Action: Accept} + switch { + case source && pr.Start == pr.End: + rule.SourcePort = pr.Start + case source: + rule.SourcePorts = []PortRange{pr} + case pr.Start == pr.End: + rule.Port = pr.Start + default: + rule.Ports = []PortRange{pr} + } + rules = append(rules, rule) + } + return rules +} + +// GetRules returns the filter rules for a zone, resolving an empty zone to the default. +func (f *FirewallD) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + zoneName, err = f.resolveZoneName(ctx, zoneName) + if err != nil { + return + } + + // Get the zone settings. + settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx) + if err != nil { + return + } + + // Named services (settings.Services) have no Rule representation and are + // intentionally not surfaced here; only ports, source ports, sources and + // rich rules map to managed rules. + + // Add port allows to rule list. A zone port entry may be a single port or a + // contiguous range (e.g. "49152-49215"), so parse it as a range and collapse + // a single-port range back onto the scalar Port field. + rules = append(rules, f.zonePortRules(settings.Ports, false)...) + + // Add bare-protocol allows (firewall-cmd --add-protocol) to the rule list. + // firewalld stores these as a zone protocol entry rather than a rich rule, so + // surface each recognized one as a portless-protocol rule; otherwise it is + // invisible to Sync/Restore and can never be reconciled. An unrecognized + // protocol has no Rule representation and is left unmanaged. + for _, proto := range settings.Protocols { + if p := GetProtocol(proto); p != ProtocolAny { + rules = append(rules, &Rule{Proto: p, Action: Accept}) + } + } + + // Add source-port allows to rule list, likewise reading a single port or a + // contiguous range. + rules = append(rules, f.zonePortRules(settings.SourcePorts, true)...) + + // Add source allows to rule list. + for _, source := range settings.Sources { + rule := &Rule{ + Source: source, + Action: Accept, + } + rules = append(rules, rule) + } + + // Parse and add rich rules. + for _, richRule := range settings.RichRules { + rule, err := f.UnmarshalRichRule(richRule) + if err != nil { + continue + } + rules = append(rules, rule) + } + + // Collapse an IPv4/IPv6 pair of otherwise-identical rules into a single + // FamilyAny rule, as every other backend's GetRules does, so a rule added + // family-agnostically reads back the same way. + rules = mergeFamilies(rules) + + // firewalld isolates rules by zone; this read is already scoped to a single + // zone, so every rule read here lives in zoneName — record the zone and flag it + // as carrying the prefix. + for _, r := range rules { + r.table = zoneName + r.HasPrefix = true + } + return +} + +// icmpTypeName returns the firewalld icmp-type name for a numeric ICMP type in +// the given family, and whether the type is expressible as a rich rule element. +func (f *FirewallD) icmpTypeName(isV6 bool, typ uint8) (string, bool) { + for name, n := range f.icmpTypeTable(isV6) { + if n == typ { + return name, true + } + } + return "", false +} + // protoValue returns the protocol name firewalld's `protocol value=` element // expects. ICMPv6 is named `ipv6-icmp` in /etc/protocols. func (f *FirewallD) protoValue(p Protocol) string { @@ -472,32 +594,6 @@ var fwICMPv6Types = map[string]uint8{ "redirect": 137, } -// icmpTypeTable selects the IPv4 or IPv6 name/number table by family. -func (f *FirewallD) icmpTypeTable(isV6 bool) map[string]uint8 { - if isV6 { - return fwICMPv6Types - } - return fwICMPv4Types -} - -// icmpTypeName returns the firewalld icmp-type name for a numeric ICMP type in -// the given family, and whether the type is expressible as a rich rule element. -func (f *FirewallD) icmpTypeName(isV6 bool, typ uint8) (string, bool) { - for name, n := range f.icmpTypeTable(isV6) { - if n == typ { - return name, true - } - } - return "", false -} - -// icmpTypeNumber returns the numeric ICMP type for a firewalld icmp-type name in -// the given family, and whether the name is known. -func (f *FirewallD) icmpTypeNumber(isV6 bool, name string) (uint8, bool) { - n, ok := f.icmpTypeTable(isV6)[strings.ToLower(name)] - return n, ok -} - // rateUnit maps a RateUnit to the single-letter time unit a firewalld rich // rule's `limit value="N/unit"` expects (s/m/h/d). func (f *FirewallD) rateUnit(u RateUnit) string { @@ -726,107 +822,27 @@ func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) { return strings.Join(parts, " "), nil } -// zonePortRules maps a firewalld zone port list (settings.Ports or SourcePorts) -// to allow rules, one per entry. source selects whether the range binds to the -// source-port or destination-port fields. A port on an unmodeled protocol (e.g. -// dccp, which GetProtocol maps to ProtocolAny) is skipped: it has no expressible -// Rule, so surfacing it would leave a rule RemoveRule and MarshalRichRule reject. -// This mirrors the protocols loop's guard. -func (f *FirewallD) zonePortRules(ports []firewalld.Port, source bool) []*Rule { - var rules []*Rule - for _, port := range ports { - pr, perr := ParsePortRange(port.Port) - if perr != nil { - continue - } - proto := GetProtocol(port.Protocol) - if !proto.HasPorts() { - continue - } - rule := &Rule{Proto: proto, Action: Accept} - switch { - case source && pr.Start == pr.End: - rule.SourcePort = pr.Start - case source: - rule.SourcePorts = []PortRange{pr} - case pr.Start == pr.End: - rule.Port = pr.Start - default: - rule.Ports = []PortRange{pr} - } - rules = append(rules, rule) +// ignoreAlreadyEnabled treats firewalld's ALREADY_ENABLED as success, making an +// add idempotent: re-adding an element that is already present is a no-op. +func (f *FirewallD) ignoreAlreadyEnabled(err error) error { + if errors.Is(err, firewalld.ErrAlreadyEnabled) { + return nil } - return rules + return err } -// GetRules returns the filter rules for a zone, resolving an empty zone to the default. -func (f *FirewallD) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { - zoneName, err = f.resolveZoneName(ctx, zoneName) - if err != nil { - return - } - - // Get the zone settings. - settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx) - if err != nil { - return - } - - // Named services (settings.Services) have no Rule representation and are - // intentionally not surfaced here; only ports, source ports, sources and - // rich rules map to managed rules. - - // Add port allows to rule list. A zone port entry may be a single port or a - // contiguous range (e.g. "49152-49215"), so parse it as a range and collapse - // a single-port range back onto the scalar Port field. - rules = append(rules, f.zonePortRules(settings.Ports, false)...) - - // Add bare-protocol allows (firewall-cmd --add-protocol) to the rule list. - // firewalld stores these as a zone protocol entry rather than a rich rule, so - // surface each recognized one as a portless-protocol rule; otherwise it is - // invisible to Sync/Restore and can never be reconciled. An unrecognized - // protocol has no Rule representation and is left unmanaged. - for _, proto := range settings.Protocols { - if p := GetProtocol(proto); p != ProtocolAny { - rules = append(rules, &Rule{Proto: p, Action: Accept}) - } - } - - // Add source-port allows to rule list, likewise reading a single port or a - // contiguous range. - rules = append(rules, f.zonePortRules(settings.SourcePorts, true)...) - - // Add source allows to rule list. - for _, source := range settings.Sources { - rule := &Rule{ - Source: source, - Action: Accept, - } - rules = append(rules, rule) - } - - // Parse and add rich rules. - for _, richRule := range settings.RichRules { - rule, err := f.UnmarshalRichRule(richRule) - if err != nil { - continue - } - rules = append(rules, rule) - } - - // Collapse an IPv4/IPv6 pair of otherwise-identical rules into a single - // FamilyAny rule, as every other backend's GetRules does, so a rule added - // family-agnostically reads back the same way. - rules = mergeFamilies(rules) - - // firewalld isolates rules by zone; this read is already scoped to a single - // zone, so every rule read here lives in zoneName — record the zone and flag it - // as carrying the prefix. - for _, r := range rules { - r.table = zoneName - r.HasPrefix = true - } - return +// sourceZoneShape reports whether a rule's non-address shape lets a plain source +// map to a firewalld zone source: no protocol match, no destination port, no +// source port, and a non-negated source. A source combined with a concrete +// protocol or a port is a rich rule (firewalld encodes those as +// `source address="..." protocol value="..."`/`port ...`), so it is excluded here +// — encoding such a rule as a bare zone source would silently drop the protocol or +// port match and widen it. AddRule and RemoveRule share this so their zone-source +// routing stays symmetric; they differ only in which source *forms* they accept (a +// MAC source is added as a rich rule but removed via the zone-source path). +func (f *FirewallD) sourceZoneShape(r *Rule) bool { + return r.Source != "" && r.Source[0] != '!' && r.Proto == ProtocolAny && + !r.HasPorts() && !r.HasSourcePorts() } // zoneEntryEligible reports whether a rule can be expressed as a firewalld @@ -917,10 +933,38 @@ func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error return f.ignoreAlreadyEnabled(zone.AddRichRule(ctx, richRule)) } +// InsertRule is unsupported: firewalld rich rules and port/source shortcuts are +// not positionally ordered. +func (f *FirewallD) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + return unsupportedOrdering(f.Type()) +} + +// MoveRule is unsupported for the same reason as InsertRule. +func (f *FirewallD) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + return unsupportedOrdering(f.Type()) +} + +// ignoreNotEnabled treats firewalld's NOT_ENABLED as success, making a remove +// idempotent: removing an element that is not present is a no-op. +func (f *FirewallD) ignoreNotEnabled(err error) error { + if errors.Is(err, firewalld.ErrNotEnabled) { + return nil + } + return err +} + +// FirewallD manages a firewalld instance over D-Bus, mapping the Manager +// interface onto firewalld's zones, rich rules, and ipsets. +type FirewallD struct { + Conn *firewalld.Conn + // rulePrefix is accepted for a consistent constructor signature across + // backends. firewalld organizes rules into zones rather than a private + // namespace, so the prefix is not applied to individual rules. + rulePrefix string +} + // isZoneSource reports whether a source string is a form firewalld stores as a // zone source: an IP, a CIDR, a MAC address, or an ipset reference (ipset:). -// RemoveRule uses it to decide whether a bare source can be cleared with -// RemoveSource rather than falling through to the rich-rule path. func (f *FirewallD) isZoneSource(s string) bool { if _, _, err := net.ParseCIDR(s); err == nil { return true @@ -937,21 +981,8 @@ func (f *FirewallD) isZoneSource(s string) bool { return false } -// sourceZoneShape reports whether a rule's non-address shape lets a plain source -// map to a firewalld zone source: no protocol match, no destination port, no -// source port, and a non-negated source. A source combined with a concrete -// protocol or a port is a rich rule (firewalld encodes those as -// `source address="..." protocol value="..."`/`port ...`), so it is excluded here -// — encoding such a rule as a bare zone source would silently drop the protocol or -// port match and widen it. AddRule and RemoveRule share this so their zone-source -// routing stays symmetric; they differ only in which source *forms* they accept (a -// MAC source is added as a rich rule but removed via the zone-source path). -func (f *FirewallD) sourceZoneShape(r *Rule) bool { - return r.Source != "" && r.Source[0] != '!' && r.Proto == ProtocolAny && - !r.HasPorts() && !r.HasSourcePorts() -} - -// RemoveRule removes a filter rule from a zone, mirroring how AddRule stored it. +// RemoveRule uses it to decide whether a bare source can be cleared with +// RemoveSource rather than falling through to the rich-rule path. func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { // A DirAny rule degrades to its input half on firewalld (no output concept), // mirroring AddRule so a both-directions rule is found and removed as stored. @@ -1120,42 +1151,6 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er return f.ignoreNotEnabled(zone.RemoveRichRule(ctx, richRule)) } -// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's -// per-zone port-forward API expects (port, protocol, toport, toaddr). That API -// carries only these four fields, so any source, destination or interface match — -// or a port list — cannot be expressed through it and is rejected. (firewalld can -// express a source-scoped forward-port in a rich rule, but this backend manages -// NAT through the zone API, which GetNATRules reads back; a rich-rule forward-port -// would not round-trip, so it is intentionally not emitted here.) -func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) { - if r.Proto != TCP && r.Proto != UDP { - return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol") - } - if !r.HasPorts() { - return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port") - } - specs := r.PortSpecs() - if len(specs) > 1 { - return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward") - } - if r.Interface != "" { - return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface") - } - if r.Source != "" || r.Destination != "" { - return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching") - } - fp := firewalld.ForwardPort{ - Port: specs[0].String(), - Protocol: r.Proto.String(), - // ToAddr is empty for a Redirect (same-host) and set for a DNAT. - ToAddr: r.ToAddress, - } - if r.ToPort != 0 { - fp.ToPort = strconv.FormatUint(uint64(r.ToPort), 10) - } - return fp, nil -} - // GetNATRules returns the NAT rules for a zone, mapping forward ports and masquerade. func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { zoneName, err = f.resolveZoneName(ctx, zoneName) @@ -1213,6 +1208,42 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []* return rules, nil } +// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's +// per-zone port-forward API expects (port, protocol, toport, toaddr). That API +// carries only these four fields, so any source, destination or interface match — +// or a port list — cannot be expressed through it and is rejected. (firewalld can +// express a source-scoped forward-port in a rich rule, but this backend manages +// NAT through the zone API, which GetNATRules reads back; a rich-rule forward-port +// would not round-trip, so it is intentionally not emitted here.) +func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) { + if r.Proto != TCP && r.Proto != UDP { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol") + } + if !r.HasPorts() { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port") + } + specs := r.PortSpecs() + if len(specs) > 1 { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward") + } + if r.Interface != "" { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface") + } + if r.Source != "" || r.Destination != "" { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching") + } + fp := firewalld.ForwardPort{ + Port: specs[0].String(), + Protocol: r.Proto.String(), + // ToAddr is empty for a Redirect (same-host) and set for a DNAT. + ToAddr: r.ToAddress, + } + if r.ToPort != 0 { + fp.ToPort = strconv.FormatUint(uint64(r.ToPort), 10) + } + return fp, nil +} + // AddNATRule adds a NAT rule to a zone via firewalld's forward-port or masquerade API. func (f *FirewallD) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { if err := r.validate(); err != nil { @@ -1248,6 +1279,12 @@ func (f *FirewallD) AddNATRule(ctx context.Context, zoneName string, r *NATRule) return fmt.Errorf("invalid nat kind") } +// InsertNATRule is unsupported: firewalld models NAT through zone toggles and rich +// rules, which carry no explicit ordering. +func (f *FirewallD) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + return unsupportedOrdering(f.Type()) +} + // RemoveNATRule removes a NAT rule from a zone via firewalld's forward-port or masquerade API. func (f *FirewallD) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { // Get the zone. @@ -1272,23 +1309,6 @@ func (f *FirewallD) RemoveNATRule(ctx context.Context, zoneName string, r *NATRu return fmt.Errorf("invalid nat kind") } -// InsertRule is unsupported: firewalld rich rules and port/source shortcuts are -// not positionally ordered. -func (f *FirewallD) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { - return unsupportedOrdering(f.Type()) -} - -// InsertNATRule is unsupported: firewalld models NAT through zone toggles and rich -// rules, which carry no explicit ordering. -func (f *FirewallD) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { - return unsupportedOrdering(f.Type()) -} - -// MoveRule is unsupported for the same reason as InsertRule. -func (f *FirewallD) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { - return unsupportedOrdering(f.Type()) -} - // Backup captures the current filter and NAT rules managed by this backend. func (f *FirewallD) Backup(ctx context.Context, zoneName string) (*Backup, error) { rules, err := f.GetRules(ctx, zoneName) @@ -1387,37 +1407,6 @@ func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup return applyBackupPolicy(ctx, f, zoneName, backup) } -// Reload reloads firewalld's permanent configuration into the runtime. -func (f *FirewallD) Reload(ctx context.Context) error { - return f.Conn.Reload(ctx) -} - -// Close releases the D-Bus connection to firewalld. -func (f *FirewallD) Close(ctx context.Context) error { - return f.Conn.Close() -} - -// Capabilities reports which optional features this backend supports. -func (f *FirewallD) Capabilities() Capabilities { - return Capabilities{ - Output: false, - Zones: true, - Priority: true, - ICMPv6: true, - PortList: false, - ConnState: false, - InterfaceMatch: false, - Logging: true, - RateLimit: true, - ConnLimit: false, - NAT: true, - RuleOrdering: false, - DefaultPolicy: true, - RuleCounters: false, - AddressSets: true, - } -} - // policyFromTarget maps a firewalld zone target to a default action. The // "default"/"%%REJECT%%"/empty targets behave as a reject, the only ones a zone // accepts explicitly being ACCEPT and DROP. @@ -1481,34 +1470,6 @@ func (f *FirewallD) SetDefaultPolicy(ctx context.Context, zoneName string, polic // --- address sets (firewalld ipsets) ---------------------------------------- -// ipSetType maps an AddressSet type to a firewalld ipset type string. -func (f *FirewallD) ipSetType(t SetType) string { - if t == SetHashNet { - return "hash:net" - } - return "hash:ip" -} - -// GetAddressSets returns all permanent firewalld ipsets as address sets. -func (f *FirewallD) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { - names, err := f.Conn.Permanent().IPSetNames(ctx) - if err != nil { - return nil, err - } - result := make([]*AddressSet, 0, len(names)) - for _, name := range names { - set, err := f.getAddressSet(ctx, name) - if err != nil { - return nil, err - } - if set == nil { - continue - } - result = append(result, set) - } - return result, nil -} - // getAddressSet reads a single firewalld ipset, or nil if it does not exist. func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) { settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx) @@ -1531,6 +1492,26 @@ func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet return set, nil } +// GetAddressSets returns all permanent firewalld ipsets as address sets. +func (f *FirewallD) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + names, err := f.Conn.Permanent().IPSetNames(ctx) + if err != nil { + return nil, err + } + result := make([]*AddressSet, 0, len(names)) + for _, name := range names { + set, err := f.getAddressSet(ctx, name) + if err != nil { + return nil, err + } + if set == nil { + continue + } + result = append(result, set) + } + return result, nil +} + // GetAddressSet returns the named permanent ipset, or an error if it does not exist. func (f *FirewallD) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { set, err := f.getAddressSet(ctx, name) @@ -1543,6 +1524,14 @@ func (f *FirewallD) GetAddressSet(ctx context.Context, name string) (*AddressSet return set, nil } +// ipSetType maps an AddressSet type to a firewalld ipset type string. +func (f *FirewallD) ipSetType(t SetType) string { + if t == SetHashNet { + return "hash:net" + } + return "hash:ip" +} + // AddAddressSet creates the permanent ipset, or updates it in place when it already exists. func (f *FirewallD) AddAddressSet(ctx context.Context, set *AddressSet) error { if set == nil || set.Name == "" { @@ -1609,3 +1598,13 @@ func (f *FirewallD) RemoveAddressSetEntry(ctx context.Context, name, entry strin } return err } + +// Reload reloads firewalld's permanent configuration into the runtime. +func (f *FirewallD) Reload(ctx context.Context) error { + return f.Conn.Reload(ctx) +} + +// Close releases the D-Bus connection to firewalld. +func (f *FirewallD) Close(ctx context.Context) error { + return f.Conn.Close() +} diff --git a/hooks_linux.go b/hooks_linux.go index aced25c..d958a92 100644 --- a/hooks_linux.go +++ b/hooks_linux.go @@ -38,12 +38,15 @@ type hookScript struct { // ruleNeedsHook reports whether a rule requires a feature that CSF/APF cannot // express in their native config and so must be injected as a raw iptables rule // through the hook: a forward-chain (routed) rule, connection-state matching, -// per-rule interface matching, logging, rate limiting, ICMPv6, or a transport +// per-rule interface matching, logging, rate limiting, ICMPv6, a transport // protocol their native config does not model (SCTP and the portless IP protocols -// GRE, ESP and AH). +// GRE, ESP and AH), or an address-set reference (@set) — csf.allow/apf trust files +// take literal addresses only, so a `-m set --match-set` match lives in the hook +// beside the ipset commands that create the set. func ruleNeedsHook(r *Rule) bool { return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" || - r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto) + r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto) || + isSetRef(r.Source) || isSetRef(r.Destination) } // bareHostShape reports whether a rule has the shape a plain csf.allow/apf @@ -55,6 +58,11 @@ func bareHostShape(r *Rule) bool { if r.HasPorts() || r.HasSourcePorts() || r.Proto != ProtocolAny { return false } + // A set reference (@set) is not a literal host: it matches through the hook's + // `-m set` clause (ruleNeedsHook routes it there), never a plain trust-file line. + if isSetRef(r.Source) || isSetRef(r.Destination) { + return false + } return (r.Source != "") != (r.Destination != "") } @@ -391,3 +399,219 @@ func (h *hookScript) edit(r *Rule, remove bool) (bool, error) { return true, h.writeHook(lines, existed) } + +// --- address sets (ipset commands in the hook) ----------------------------- +// +// CSF and APF have no native address-set construct, so the library persists a +// set as `ipset` commands in the same hook that carries its raw iptables rules. +// The firewall sources the hook on every (re)start, so the ipset commands +// recreate the set before the `-m set --match-set` rule lines that follow can +// reference it — the set survives a reboot exactly as the hook's rules do. Every +// ipset line is kept ahead of every iptables/ip6tables line to preserve that +// ordering. Reading foreign, user-authored ipset lines is intended, as with +// rules: the library manages the actual hook state. + +// ipsetLinesFor renders the hook lines that (re)create a set and load its +// entries: an idempotent create (-exist, so a reload does not fail on the +// existing set), a flush (so a reload drops entries removed since the last +// write, making the entry list declarative), then one add per entry. +func ipsetLinesFor(set *AddressSet) []string { + fam := "inet" + if set.Family == IPv6 { + fam = "inet6" + } + lines := []string{ + fmt.Sprintf("ipset create %s %s family %s -exist", set.Name, set.Type.String(), fam), + fmt.Sprintf("ipset flush %s", set.Name), + } + for _, e := range set.Entries { + lines = append(lines, fmt.Sprintf("ipset add %s %s", set.Name, e)) + } + return lines +} + +// hookIPSetName returns the set a hook ipset line operates on, or "" when the +// line is not one of the library's ipset commands. Every such line names the set +// in its third field (`ipset ...`). +func hookIPSetName(line string) string { + f := strings.Fields(line) + if len(f) >= 3 && f[0] == "ipset" { + return f[2] + } + return "" +} + +// isHookRuleLine reports whether a hook line is an iptables/ip6tables command, as +// opposed to an ipset command or user-authored shell. +func isHookRuleLine(line string) bool { + t := strings.TrimSpace(line) + return strings.HasPrefix(t, "iptables ") || strings.HasPrefix(t, "ip6tables ") +} + +// setInUse reports whether any hook rule line references name through an +// `-m set --match-set ` match, so a set is not removed out from under a +// rule that still uses it (the kernel enforces the same on a live destroy). +func setInUse(lines []string, name string) bool { + for _, l := range lines { + if !isHookRuleLine(l) { + continue + } + f := strings.Fields(l) + for i := 0; i+1 < len(f); i++ { + if f[i] == "--match-set" && f[i+1] == name { + return true + } + } + } + return false +} + +// getAddressSets parses the sets the hook carries, in the order their create +// lines appear. An ipset is pinned to a single family, so each create yields one +// set and its add lines supply the entries; flush lines carry no state and are +// ignored. +func (h *hookScript) getAddressSets() ([]*AddressSet, error) { + lines, _, err := h.readHookLines() + if err != nil { + return nil, err + } + // ipsetParseType is an IPTables method that ignores its receiver; a zero value + // reuses the same create-line parser the iptables backend uses. + ipt := &IPTables{} + sets := map[string]*AddressSet{} + var order []string + for _, line := range lines { + f := strings.Fields(line) + if len(f) >= 4 && f[0] == "ipset" && f[1] == "create" { + // ipsetParseType scans a `create NAME family ...` slice from + // its third element, so drop the leading `ipset` word to line it up. + fam, typ := ipt.ipsetParseType(f[1:]) + sets[f[2]] = &AddressSet{Name: f[2], Family: fam, Type: typ} + order = append(order, f[2]) + } + } + for _, line := range lines { + f := strings.Fields(line) + if len(f) == 4 && f[0] == "ipset" && f[1] == "add" { + if s, ok := sets[f[2]]; ok { + s.Entries = append(s.Entries, f[3]) + } + } + } + out := make([]*AddressSet, 0, len(order)) + for _, n := range order { + out = append(out, sets[n]) + } + return out, nil +} + +// editAddressSet writes or removes a set's ipset lines in the hook. Adding drops +// any prior lines for the set and reinserts its block ahead of the first +// iptables/ip6tables line, so the set exists before any rule matches it; the +// write is idempotent. Removing drops the set's lines but refuses when a hook +// rule still references it. Every other hook line — user shell, rules, other +// sets — is preserved; it reports whether the hook changed. +func (h *hookScript) editAddressSet(set *AddressSet, remove bool) (bool, error) { + lines, existed, err := h.readHookLines() + if err != nil { + return false, err + } + + if remove && setInUse(lines, set.Name) { + return false, fmt.Errorf("address set %q is in use by a rule", set.Name) + } + + // Drop any existing lines for this set (idempotent re-add; also the removal path). + kept := make([]string, 0, len(lines)) + dropped := false + for _, l := range lines { + if hookIPSetName(l) == set.Name { + dropped = true + continue + } + kept = append(kept, l) + } + + if remove { + if !dropped { + return false, nil + } + return true, h.writeHook(kept, existed) + } + + // Insert the set's block ahead of the first rule line (or at the end when the + // hook has none yet), keeping every ipset line before every rule line. + block := ipsetLinesFor(set) + next := make([]string, 0, len(kept)+len(block)) + inserted := false + for _, l := range kept { + if !inserted && isHookRuleLine(l) { + next = append(next, block...) + inserted = true + } + next = append(next, l) + } + if !inserted { + next = append(next, block...) + } + + if equalLines(lines, next) { + return false, nil + } + return true, h.writeHook(next, existed) +} + +// editAddressSetEntry adds or removes a single entry in an existing set by +// rewriting the set's block. The set must already exist in the hook. +func (h *hookScript) editAddressSetEntry(name, entry string, remove bool) (bool, error) { + sets, err := h.getAddressSets() + if err != nil { + return false, err + } + var target *AddressSet + for _, s := range sets { + if s.Name == name { + target = s + break + } + } + if target == nil { + return false, fmt.Errorf("address set %q not found", name) + } + if remove { + next := target.Entries[:0] + found := false + for _, e := range target.Entries { + if e == entry { + found = true + continue + } + next = append(next, e) + } + if !found { + return false, nil + } + target.Entries = next + } else { + for _, e := range target.Entries { + if e == entry { + return false, nil + } + } + target.Entries = append(target.Entries, entry) + } + return h.editAddressSet(target, false) +} + +// equalLines reports whether two hook line slices are identical. +func equalLines(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/hooks_linux_test.go b/hooks_linux_test.go index f625c60..4f73dfe 100644 --- a/hooks_linux_test.go +++ b/hooks_linux_test.go @@ -21,6 +21,10 @@ func TestRuleNeedsHook(t *testing.T) { // A forward rule has no native CSF/APF config path, so it routes through the // raw-iptables hook (which emits an -A FORWARD rule). {Direction: DirForward, Proto: TCP, Port: 8080, Action: Accept}, + // A set-referencing rule (Source names an ipset, not an address) has no + // literal trust-file form, so it routes through the hook beside the ipset + // commands that create the set. + {Family: IPv4, Source: "blocklist", Action: Drop}, } for _, r := range needs { require.True(t, ruleNeedsHook(r), "expected %+v to need the hook", *r) @@ -446,3 +450,134 @@ func TestHostNeedsHook(t *testing.T) { require.Equal(t, c.want, hostNeedsHook(c.rule), c.name) } } + +// A set reference is not a literal host, so bareHostShape must reject it (else +// APF/CSF would write the set name into a trust file) while ruleNeedsHook routes +// it to the hook. A literal address keeps the opposite verdicts. +func TestSetRefIsNotBareHost(t *testing.T) { + setRef := &Rule{Family: IPv4, Source: "blocklist", Action: Drop} + require.False(t, bareHostShape(setRef), "a set reference is not a bare host") + require.True(t, ruleNeedsHook(setRef), "a set reference routes to the hook") + + literal := &Rule{Family: IPv4, Source: "10.0.0.1", Action: Drop} + require.True(t, bareHostShape(literal), "a literal address is a bare host") + require.False(t, ruleNeedsHook(literal), "a literal-address host stays native") +} + +func newTestHook(t *testing.T) *hookScript { + t.Helper() + return &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(t.TempDir(), "csfpre.sh"), + hookPerm: 0700, + } +} + +// A set written to the hook round-trips through getAddressSets with its family, +// type and entries intact, for both IPv4 and IPv6, and re-adding an identical set +// is idempotent. +func TestHookAddressSetRoundTrip(t *testing.T) { + h := newTestHook(t) + + v4 := &AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashNet, Entries: []string{"192.0.2.0/24", "198.51.100.7"}} + changed, err := h.editAddressSet(v4, false) + require.NoError(t, err) + require.True(t, changed) + + // Re-adding the identical set does not rewrite the hook. + changed, err = h.editAddressSet(v4, false) + require.NoError(t, err) + require.False(t, changed, "re-adding an identical set must be idempotent") + + v6 := &AddressSet{Name: "v6drop", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}} + _, err = h.editAddressSet(v6, false) + require.NoError(t, err) + + sets, err := h.getAddressSets() + require.NoError(t, err) + require.Len(t, sets, 2) + byName := map[string]*AddressSet{} + for _, s := range sets { + byName[s.Name] = s + } + require.Equal(t, IPv4, byName["blocklist"].Family) + require.Equal(t, SetHashNet, byName["blocklist"].Type) + require.ElementsMatch(t, []string{"192.0.2.0/24", "198.51.100.7"}, byName["blocklist"].Entries) + require.Equal(t, IPv6, byName["v6drop"].Family) + require.Equal(t, SetHashIP, byName["v6drop"].Type) + require.Equal(t, []string{"2001:db8::1"}, byName["v6drop"].Entries) +} + +// The ipset commands for a set must be written ahead of any rule that references +// it, even when the rule was added first, so the set exists when the hook runs. +func TestHookAddressSetOrderedBeforeRules(t *testing.T) { + h := newTestHook(t) + + // Add the referencing rule first — edit appends it at the end of the hook. + _, err := h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, false) + require.NoError(t, err) + // Then add the set; its block must be spliced in before the rule line. + _, err = h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false) + require.NoError(t, err) + + data, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + body := string(data) + ipsetAt := strings.Index(body, "ipset create blocklist") + ruleAt := strings.Index(body, "--match-set blocklist") + require.GreaterOrEqual(t, ipsetAt, 0, "the create command must be present") + require.GreaterOrEqual(t, ruleAt, 0, "the referencing rule must be present") + require.Less(t, ipsetAt, ruleAt, "ipset commands must precede the rule that references the set") +} + +// Removing a set a rule still references is refused (the kernel enforces the same +// on a live destroy); once the rule is gone the removal succeeds. +func TestHookAddressSetInUseGuard(t *testing.T) { + h := newTestHook(t) + _, err := h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false) + require.NoError(t, err) + _, err = h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, false) + require.NoError(t, err) + + _, err = h.editAddressSet(&AddressSet{Name: "blocklist"}, true) + require.Error(t, err, "removing a set a rule references must fail") + + _, err = h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, true) + require.NoError(t, err) + changed, err := h.editAddressSet(&AddressSet{Name: "blocklist"}, true) + require.NoError(t, err) + require.True(t, changed) + sets, err := h.getAddressSets() + require.NoError(t, err) + require.Empty(t, sets, "the set must be gone after removal") +} + +// Entry edits add and remove a single address in an existing set idempotently, +// and editing a set that does not exist is an error. +func TestHookAddressSetEntryEdits(t *testing.T) { + h := newTestHook(t) + _, err := h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false) + require.NoError(t, err) + + changed, err := h.editAddressSetEntry("blocklist", "203.0.113.9", false) + require.NoError(t, err) + require.True(t, changed) + changed, err = h.editAddressSetEntry("blocklist", "203.0.113.9", false) + require.NoError(t, err) + require.False(t, changed, "adding an existing entry must be idempotent") + + sets, err := h.getAddressSets() + require.NoError(t, err) + require.Len(t, sets, 1) + require.ElementsMatch(t, []string{"203.0.113.5", "203.0.113.9"}, sets[0].Entries) + + changed, err = h.editAddressSetEntry("blocklist", "203.0.113.5", true) + require.NoError(t, err) + require.True(t, changed) + sets, err = h.getAddressSets() + require.NoError(t, err) + require.Equal(t, []string{"203.0.113.9"}, sets[0].Entries) + + _, err = h.editAddressSetEntry("missing", "1.2.3.4", false) + require.Error(t, err, "editing an entry in a set that does not exist must fail") +} diff --git a/iptables_linux.go b/iptables_linux.go index 81321dd..c8c8c8e 100644 --- a/iptables_linux.go +++ b/iptables_linux.go @@ -5,8 +5,10 @@ import ( "context" "errors" "fmt" + "log" "net" "os" + "path/filepath" "strconv" "strings" @@ -30,6 +32,13 @@ type IPTables struct { 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 unit 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 @@ -42,6 +51,38 @@ type IPTables struct { type iptLayout struct { ip4Path, ip6Path string ip4Service, ip6Service string + // ipsetPath is the save file the ipset restore unit reads on boot, and + // ipsetService is the unit that restores it before the rules unit 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 unit, 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.service", ip6Service: "netfilter-persistent.service", + ipsetPath: "/etc/iptables/ipsets", ipsetService: "netfilter-persistent.service", + 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 @@ -55,25 +96,7 @@ func probeRHELLayout(root string) (l iptLayout, ok bool) { l = iptLayout{ ip4Path: "/etc/sysconfig/iptables", ip6Path: "/etc/sysconfig/ip6tables", ip4Service: "iptables.service", ip6Service: "ip6tables.service", - } - if _, err := os.Stat(root + l.ip4Path); err != nil { - return iptLayout{}, false - } - if _, err := os.Stat(root + l.ip6Path); err != nil { - return iptLayout{}, false - } - return l, true -} - -// probeDebianLayout reports the Debian/Ubuntu iptables-persistent layout -// (/etc/iptables/rules.v4 and rules.v6, both restored by the single -// netfilter-persistent.service) if its save files are both present under -// root. root is prepended to both paths so tests can point the probe at a -// temp dir; production callers pass "". -func probeDebianLayout(root string) (l iptLayout, ok bool) { - l = iptLayout{ - ip4Path: "/etc/iptables/rules.v4", ip6Path: "/etc/iptables/rules.v6", - ip4Service: "netfilter-persistent.service", ip6Service: "netfilter-persistent.service", + ipsetPath: "/etc/sysconfig/ipset", ipsetService: "ipset.service", } if _, err := os.Stat(root + l.ip4Path); err != nil { return iptLayout{}, false @@ -136,20 +159,50 @@ func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) { } } + // 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 } -// requireUnitEnabled returns IPTablesNoService unless service's UnitFileState -// is "enabled". -func (f *IPTables) requireUnitEnabled(ctx context.Context, service string) error { - prop, err := f.Conn.GetUnitPropertyContext(ctx, service, "UnitFileState") +// detectIPSetLayout reports the ipset save file and restore unit 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 unit (proven by its unit file 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 _, present, err := f.unitFileState(ctx, layout.ipsetService); err != nil || !present { + return "", "" + } + return layout.ipsetPath, layout.ipsetService +} + +// unitFileState returns service's enablement state (e.g. "enabled", "disabled", +// "static") and whether its unit file is installed. It reads the full unit-file +// list and filters by name rather than calling ListUnitFilesByPatterns, which +// older systemd (CentOS 7's v219) does not export over D-Bus. +func (f *IPTables) unitFileState(ctx context.Context, service string) (state string, present bool, err error) { + files, err := f.Conn.ListUnitFilesContext(ctx) if err != nil { - return fmt.Errorf("error getting service %s property: %s", service, err) + return "", false, err } - if prop.Value.Value() != "enabled" { - return errors.New(IPTablesNoService) + for _, uf := range files { + if filepath.Base(uf.Path) == service { + return uf.Type, true, nil + } } - return nil + return "", false, nil } // Type returns the manager type. @@ -157,28 +210,73 @@ 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 } -// 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 +// 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 } - // 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 + 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 ...` @@ -738,97 +836,317 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error) return } -// iptMultiportValue renders port specs for `-m multiport --dports`, using a -// colon for ranges (e.g. "80,443,1000:2000"). The specs are canonicalized -// (sorted, with contiguous/overlapping ranges merged) so that two rules the model -// considers Equal — port-set order and coalescing are not part of rule identity — -// always render to the same string. Backends that match on the exact marshalled -// line (the CSF/APF hook script) rely on this to stay idempotent. -func iptMultiportValue(specs []PortRange) string { - specs = coalescePortRanges(specs) - parts := make([]string, len(specs)) - for i, pr := range specs { - if pr.Start == pr.End { - parts[i] = strconv.FormatUint(uint64(pr.Start), 10) - } else { - parts[i] = fmt.Sprintf("%d:%d", pr.Start, pr.End) +// 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 after merging families + // across the two save files (the other callers use the result only for + // EqualBase dedup and never read Number). + return coalesceLoggedRules(perLine), nil +} + +// GetRules returns the existing filter rules from the zone. +func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + v4, err := f.parseFilterFile(f.IP4Path, IPv4) + if err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err) + } + rules = append(rules, v4...) + + v6, err := f.parseFilterFile(f.IP6Path, IPv6) + if err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err) + } + rules = append(rules, v6...) + + // Merge rules across families, number per direction (before the direction + // merge, so surviving output rows keep the physical position of their still- + // present twin), then collapse each input/output twin into one DirAny rule. + rules = mergeFamilies(rules) + numberByDirection(rules) + rules = mergeDirections(rules) + + return +} + +// applyRuleFiles runs prepare against each family file the rule applies to, +// staging all temp files first and committing them only once every file has +// been prepared successfully. A FamilyAny rule touches both the IPv4 and IPv6 +// files, so staging up front avoids leaving the rule half-applied if the second +// file fails to prepare. +func (f *IPTables) applyRuleFiles(r *Rule, prepare func(string, *Rule) (*atomicFile, error)) error { + // A DirAny rule fans out into an input row plus its role-swapped output row, + // each marshalled into its own chain within the same family file(s). Recurse per + // concrete-direction half; expandDirections returns a single element for a + // concrete rule, so this never recurses more than once. + if subs := expandDirections(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.applyRuleFiles(sub, prepare); err != nil { + return err + } + } + return nil + } + + // Resolve the family, letting an ICMP/ICMPv6 protocol pin it: `-p icmp` + // belongs only in the IPv4 file and `-p icmpv6` only in the IPv6 file. + family := r.impliedFamily() + var paths []string + if family == IPv4 || family == FamilyAny { + paths = append(paths, f.IP4Path) + } + if family == IPv6 || family == FamilyAny { + paths = append(paths, f.IP6Path) + } + + // Stage every file first. + var staged []*atomicFile + for _, path := range paths { + af, err := prepare(path, r) + if err != nil { + // Discard any temp files already staged. + for _, s := range staged { + s.Abort() + } + return err + } + // A nil handle means no change was needed for this file. + if af != nil { + staged = append(staged, af) } } - return strings.Join(parts, ",") -} -// stateValue renders a conntrack state set as an upper-case comma list (e.g. -// "NEW,ESTABLISHED"). -func (f *IPTables) stateValue(s ConnState) string { - names := s.Strings() - for i, n := range names { - names[i] = strings.ToUpper(n) - } - return strings.Join(names, ",") -} - -// iptParsePorts parses a multiport value list (comma-separated "p" or "lo:hi") -// into PortRange values. -func iptParsePorts(val string) ([]PortRange, error) { - return ParsePortRanges(val, ",") -} - -// quoteCommentToken double-quotes v for a comment/log-prefix token in an -// iptables-save-format rule line, escaping only backslash and double-quote so it -// round-trips through the shlex.Split reader (strconv.Quote is unusable: its -// \t/\n/\uXXXX escapes are not un-escaped by shlex). A literal newline or -// carriage return is rejected outright, since it would split the one-line rule. -func (f *IPTables) quoteCommentToken(v string) (string, error) { - if strings.ContainsAny(v, "\n\r") { - return "", fmt.Errorf("a comment cannot contain a newline") - } - var b strings.Builder - b.WriteByte('"') - for _, r := range v { - if r == '\\' || r == '"' { - b.WriteByte('\\') + // 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) } - b.WriteRune(r) } - b.WriteByte('"') - return b.String(), nil + return nil } -// combineComment joins the configured prefix and an optional user comment into the -// single comment string stored on a rule. The prefix is always carried so rules -// this library creates stay identifiable: when both are present the prefix is -// followed by a space and the user text; when only one is present it is used -// alone; when neither is present the result is empty. -func combineComment(prefix, comment string) string { - if prefix == "" { - return comment - } - if comment == "" { - return prefix - } - return prefix + " " + comment +// 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) } -// 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 +// 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] } - if comment == prefix { - return "", true + 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:]) + } } - if rest, ok := strings.CutPrefix(comment, prefix+" "); ok { - return rest, true + 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 comment, false + 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 +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 } // addrArgs encodes a source or destination match. dir is "src" or "dst". An @@ -857,19 +1175,38 @@ func (f *IPTables) addrArgs(addr, dir string) []string { return append(out, flag, bare) } -// 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 -// 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" +// 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 } - return "INPUT" + 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 @@ -886,7 +1223,40 @@ func iptablesRuleValid(r *Rule) error { return nil } -// it so a logged rule's two lines carry identical matches. +// 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) { if err := iptablesRuleValid(r); err != nil { return nil, err @@ -1045,319 +1415,19 @@ func (f *IPTables) marshalRuleLines(r *Rule) ([]string, error) { return []string{logLine, action}, nil } -// iptSameMatch reports whether two rules have identical match fields ignoring -// their action and logging flags. It is used to pair a LOG line with the action -// line that follows it. -func iptSameMatch(a, b *Rule) bool { - ac, bc := *a, *b - ac.Log, bc.Log = false, false - ac.LogPrefix, bc.LogPrefix = "", "" - ac.Action, bc.Action = Accept, Accept - return ac.EqualBase(&bc, true) -} - -// chainRules parses the `-A` lines of chain in the *filter table of lines, in -// file order, returning one entry per line (nil for a line the parser rejects, -// which counts as an ordinary foreign rule). It scopes to the *filter table so a -// *nat/*mangle INPUT/OUTPUT chain is never counted, and feeds logicalStarts. -func (f *IPTables) chainRules(lines []string, chain string) []*Rule { - var rules []*Rule - filterFound := false - for _, raw := range lines { - line := strings.TrimSpace(raw) - if !filterFound { - if line == "*filter" { - filterFound = true - } - continue - } - // Leave the filter table at its COMMIT or the next table header so a - // *nat/*mangle INPUT/OUTPUT chain is not counted. - if strings.HasPrefix(line, "*") || line == "COMMIT" { - break - } - if f.chainOf(f.ruleLineBody(line)) != chain { - continue - } - rule, _ := f.UnmarshalRule(line, FamilyAny) - rules = append(rules, rule) - } - return rules -} - -// logicalStarts maps each entry of rules (the parsed `-A` lines of one chain, -// in file order) to the 1-based logical-rule position it begins, or 0 when it is -// not a logical-rule start — an action line coalesced into a preceding LOG line, -// or a dropped orphan LOG line. It mirrors coalesceLoggedRules exactly so an -// insert/move position aligns with the per-chain numbering GetRules reports: a -// LOG line paired with its action is one logical rule beginning at the LOG line, -// while an orphan LOG line (no matching action after it) begins none. The -// returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and -// prepareMoveRuleFile can look up a physical line's position as they scan. -func (f *IPTables) logicalStarts(rules []*Rule) []int { - starts := make([]int, len(rules)) - pos := 0 - for i := 0; i < len(rules); i++ { - cur := rules[i] - if cur != nil && cur.Action == ActionInvalid && cur.Log { - var next *Rule - if i+1 < len(rules) { - next = rules[i+1] - } - if logPartner(cur, next) { - // A LOG line paired with its action is one logical rule that begins - // at the LOG line; the action partner (starts[i+1]) stays 0. - pos++ - starts[i] = pos - i++ - continue - } - // An orphan LOG line is dropped by GetRules, so it begins no logical rule. - continue - } - pos++ - starts[i] = pos - } - return starts -} - -// logPartner reports whether cur is a standalone LOG line (no terminal action) -// and next is its action partner — the pairing GetRules coalesces into one -// logged rule (see iptSameMatch). next may be nil when cur is the last rule in -// the sequence. It is the shared predicate behind coalesceLoggedRules, -// preservedFilterLines and logicalStarts, which walk the same LOG+action -// pairing over different sequence shapes (parsed rules vs. save-file lines). -func logPartner(cur, next *Rule) bool { - return cur != nil && cur.Action == ActionInvalid && cur.Log && - next != nil && next.Action != ActionInvalid && iptSameMatch(cur, next) -} - -// mergeLogPair folds a standalone LOG line's prefix into its action partner, -// producing the single logical rule GetRules reports for a logged rule. -func mergeLogPair(logLine, action *Rule) *Rule { - merged := *action - merged.Log = true - merged.LogPrefix = logLine.LogPrefix - return &merged -} - -// coalesceLoggedRules merges each LOG-only rule that is immediately followed by -// a matching action rule into a single logical rule with Log set. An orphan LOG -// rule (no matching action after it) is dropped. -func coalesceLoggedRules(rules []*Rule) []*Rule { - out := make([]*Rule, 0, len(rules)) - for i := 0; i < len(rules); i++ { - cur := rules[i] - if cur.Action == ActionInvalid && cur.Log { - // A LOG-only line: fold it into the next line if that line is its - // action partner, else drop this orphan LOG line. - var next *Rule - if i+1 < len(rules) { - next = rules[i+1] - } - if logPartner(cur, next) { - out = append(out, mergeLogPair(cur, next)) - i++ - } - continue - } - out = append(out, cur) - } - return out -} - -// IgnoreLine reports whether an iptables-save line is a blank, comment, table, chain or COMMIT line to be skipped. -func (*IPTables) IgnoreLine(line string) bool { - if len(line) == 0 { - return true - } - if line[0] == '#' || line[0] == '*' || line[0] == ':' { - return true - } - if line == "COMMIT" { - return true - } - return false -} - -// parseFilterFile reads a family's iptables-save file and returns its filter -// rules as logical rules, coalescing each LOG line with the action line that -// follows it. Lines that do not parse (nat rules, custom chains) are skipped. -func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) { +// 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 perLine []*Rule + var lines []string 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) + lines = append(lines, scanner.Text()) } - if err := scanner.Err(); err != nil { - return nil, err - } - // Return the coalesced rules; GetRules assigns Number after merging families - // across the two save files (the other callers use the result only for - // EqualBase dedup and never read Number). - return coalesceLoggedRules(perLine), nil -} - -// GetRules returns the existing filter rules from the zone. -func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { - v4, err := f.parseFilterFile(f.IP4Path, IPv4) - if err != nil { - return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err) - } - rules = append(rules, v4...) - - v6, err := f.parseFilterFile(f.IP6Path, IPv6) - if err != nil { - return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err) - } - rules = append(rules, v6...) - - // Merge rules across families, number per direction (before the direction - // merge, so surviving output rows keep the physical position of their still- - // present twin), then collapse each input/output twin into one DirAny rule. - rules = mergeFamilies(rules) - numberByDirection(rules) - rules = mergeDirections(rules) - - return -} - -// prepareAddRuleFile writes an updated copy of filePath, with r inserted, to a -// staged atomicFile and returns it uncommitted. It returns a nil handle (and nil -// error) when no change is needed because the rule already exists. On error the -// staged file is cleaned up. The caller is responsible for committing a returned -// handle (or aborting it). -func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, error) { - // Skip if an equivalent logical rule already exists (LOG+action lines are - // coalesced, so a logged rule is compared as one unit). - existing, err := f.parseFilterFile(filePath, FamilyAny) - if err != nil { - return nil, err - } - for _, e := range existing { - if e.EqualBase(r, true) { - return nil, nil - } - } - - // Encode the rule's line(s): a logged rule is a LOG line plus an action line. - ruleLines, err := f.marshalRuleLines(r) - if err != nil { - return nil, err - } - - fd, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer func() { _ = fd.Close() }() - - // Stage the rewrite, preserving the save file's mode and ownership. - af, err := newAtomicFile(filePath, 0644) - if err != nil { - return nil, err - } - - // Scan each line to find where we should insert our rule. - scanner := bufio.NewScanner(fd) - writtenRule := false - filterFound := false - writeRule := func() { - for _, l := range ruleLines { - _, _ = fmt.Fprintln(af, l) - } - writtenRule = true - } - for scanner.Scan() { - // Trim line and check if we skip the line. - line := strings.TrimSpace(scanner.Text()) - - // Look for the filter, and skip if not reached. - if !filterFound { - if line == "*filter" { - filterFound = true - } - _, _ = fmt.Fprintln(af, line) - continue - } - - // Insert the new rule before the first existing rule of any chain (or - // before COMMIT when the table has no rules yet), so AddRule places it at - // the top of the chain. - if !writtenRule { - if line == "COMMIT" { - writeRule() - } else if strings.HasPrefix(f.ruleLineBody(line), "-A ") { - writeRule() - } - } - - // Write the original line back to the new file. - _, _ = fmt.Fprintln(af, line) - } - - // A read error means the staged file is truncated; discard it rather than - // installing a partial ruleset. - if err := scanner.Err(); err != nil { - af.Abort() - return nil, err - } - - // A rule that was never written means the filter table was malformed. - if !writtenRule { - af.Abort() - return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file") - } - - return af, nil -} - -// AddRule adds a rule to the zone. -func (f *IPTables) AddRule(ctx context.Context, zoneName string, r *Rule) error { - return f.applyRuleFiles(r, f.prepareAddRuleFile) -} - -// InsertRule inserts rule before the given 1-based position in the iptables save -// file. A non-positive position is treated as 1; a position larger than the -// current rule count appends the rule. -func (f *IPTables) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { - return f.applyRuleFiles(r, func(path string, r *Rule) (*atomicFile, error) { - return f.prepareInsertRuleFile(path, r, position) - }) + return lines, scanner.Err() } // prepareInsertRuleFile is like prepareAddRuleFile but inserts the rule at the @@ -1465,94 +1535,15 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int) return af, nil } -// MoveRule moves an existing rule to the given 1-based position within its chain. -func (f *IPTables) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { +// 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.prepareMoveRuleFile(path, r, position) + return f.prepareInsertRuleFile(path, r, position) }) } -// prepareMoveRuleFile removes the first matching rule and re-inserts it at the -// given 1-based position within its chain. -func (f *IPTables) prepareMoveRuleFile(filePath string, r *Rule, position int) (*atomicFile, error) { - if position <= 0 { - position = 1 - } - - lines, err := f.readAllLines(filePath) - if err != nil { - return nil, err - } - - // Exact chain-name compare so a foreign chain whose name starts with - // INPUT/OUTPUT is not counted (see prepareInsertRuleFile). - expectedChain := iptChainForDirection(r.Direction) - - extracted, removedIdx, err := f.extractRuleLines(lines, r) - if err != nil { - return nil, err - } - if removedIdx < 0 { - return nil, nil - } - - without := make([]string, 0, len(lines)-len(extracted)) - for i, l := range lines { - if i >= removedIdx && i < removedIdx+len(extracted) { - continue - } - without = append(without, l) - } - - // Re-insert at the requested 1-based position. A position past the last rule in - // the chain falls through to the COMMIT branch below, which appends after the - // chain's last rule — so no explicit rule count or clamp is needed here (see - // prepareInsertRuleFile, which relies on the same COMMIT fallback). - // Precompute the 1-based logical position each in-chain line begins over the - // post-removal lines, mirroring GetRules' numbering so the re-inserted rule - // lands at the requested position and never between a LOG line and its action. - chainStarts := f.logicalStarts(f.chainRules(without, expectedChain)) - chainIdx := 0 - - out := make([]string, 0, len(without)+len(extracted)) - filterFound := false - inserted := false - for _, raw := range without { - line := strings.TrimSpace(raw) - - if !filterFound { - if line == "*filter" { - filterFound = true - } - out = append(out, raw) - continue - } - - if !inserted && f.chainOf(f.ruleLineBody(line)) == expectedChain { - pos := 0 - if chainIdx < len(chainStarts) { - pos = chainStarts[chainIdx] - } - chainIdx++ - if pos == position { - out = append(out, extracted...) - inserted = true - } - } - if !inserted && line == "COMMIT" { - out = append(out, extracted...) - inserted = true - } - out = append(out, raw) - } - - if !inserted { - return nil, fmt.Errorf("we were not able to move the rule in the iptables-save file") - } - - return f.stageLines(filePath, out) -} - // extractRuleLines returns the raw save-file lines belonging to the first rule // equal to r, the index where they start, or a negative index when the rule is // not present. It coalesces LOG+action lines for logged rules. @@ -1634,520 +1625,6 @@ func (f *IPTables) extractRuleLines(lines []string, r *Rule) ([]string, int, err return nil, -1, nil } -// prepareRemoveRuleFile writes a copy of filePath, with r removed, to a staged -// atomicFile and returns it uncommitted. It returns a nil handle (and nil error) -// when the rule was not present so no change is needed. On error the staged file -// is cleaned up. The caller is responsible for committing a returned handle (or -// aborting it). -// -// It shares its rule-location logic with prepareMoveRuleFile: both locate the -// first rule equal to r (and its LOG partner, if any) via extractRuleLines and -// splice those lines out, so the LOG+action pairing and the *nat/*mangle scoping -// it depends on are defined in exactly one place. -func (f *IPTables) prepareRemoveRuleFile(filePath string, r *Rule) (*atomicFile, error) { - lines, err := f.readAllLines(filePath) - if err != nil { - return nil, err - } - - extracted, removedIdx, err := f.extractRuleLines(lines, r) - if err != nil { - return nil, err - } - if removedIdx < 0 { - // The rule was not present; no change is needed. - return nil, nil - } - - without := make([]string, 0, len(lines)-len(extracted)) - for i, l := range lines { - if i >= removedIdx && i < removedIdx+len(extracted) { - continue - } - without = append(without, l) - } - - return f.stageLines(filePath, without) -} - -// RemoveRule removes a rule from the zone. -func (f *IPTables) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { - return f.applyRuleFiles(r, f.prepareRemoveRuleFile) -} - -// AddRulesBatch adds every rule in a single rewrite of each family's save file, -// rather than one read-modify-write per rule. It implements RuleBatcher. -func (f *IPTables) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { - return f.applyRulesBatch(rules, false) -} - -// ReplaceRulesBatch rewrites each family's filter table to hold exactly rules, -// preserving the nat table and chain policies. It implements RuleBatcher. -func (f *IPTables) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { - return f.applyRulesBatch(rules, true) -} - -// applyRulesBatch rewrites the *filter table of each family file so it holds the -// requested rules. When replace is false the existing filter rules are kept and -// the new rules appended (skipping duplicates); when true the filter rules are -// replaced outright. The nat table and chain-policy lines are preserved. -func (f *IPTables) applyRulesBatch(rules []*Rule, replace bool) error { - // Fan each DirAny rule out into an input row plus its swapped output row before - // the per-family loop, so each half marshals into its own chain. - var expanded []*Rule - for _, r := range rules { - expanded = append(expanded, expandDirections(r)...) - } - rules = expanded - - for _, fam := range []Family{IPv4, IPv6} { - path := f.IP4Path - if fam == IPv6 { - path = f.IP6Path - } - - // Assemble the desired rule set for this family. - var desired []*Rule - if !replace { - existing, err := f.parseFilterFile(path, fam) - if err != nil { - return err - } - desired = append(desired, existing...) - } - for _, r := range rules { - rf := r.impliedFamily() - if rf != FamilyAny && rf != fam { - continue - } - c := *r - if c.Family == FamilyAny { - c.Family = fam - } - dup := false - for _, e := range desired { - if e.EqualBase(&c, true) { - dup = true - break - } - } - if dup { - continue - } - desired = append(desired, &c) - } - - // Marshal the desired rules to save-file lines. - var ruleLines []string - for _, r := range desired { - rl, err := f.marshalRuleLines(r) - if err != nil { - return err - } - ruleLines = append(ruleLines, rl...) - } - - if err := f.rewriteFilterRules(path, ruleLines); err != nil { - return err - } - } - return nil -} - -// ruleLineBody strips an optional leading [pkts:bytes] counter token from a -// trimmed iptables-save line and returns the remaining rule body. iptables-save -// -c annotates each rule with counters; the library never emits them, but the -// file-rewrite paths must still recognise a counter-prefixed line as a rule when -// operating on a pre-existing save file (matching the read parser, which strips -// the same prefix). A line without a counter is returned unchanged. -func (f *IPTables) ruleLineBody(line string) string { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "[") { - if i := strings.IndexByte(line, ']'); i >= 0 { - return strings.TrimSpace(line[i+1:]) - } - } - return line -} - -// chainOf returns the chain named by an iptables-save rule body such as -// "-A FORWARD -j ACCEPT" (the token after the -A/-I/-R command), or "" when the -// body has no chain token. It lets the file-rewrite paths tell an INPUT/OUTPUT -// rule the library manages from a rule in a chain it does not model. -func (f *IPTables) chainOf(body string) string { - fields := strings.Fields(body) - if len(fields) >= 2 { - return fields[1] - } - return "" -} - -// modeledFilterChain reports whether a *filter chain name is one the library -// models as a Rule direction (INPUT, OUTPUT or FORWARD). The file-rewrite paths -// use it to tell a managed rule from a rule in a chain the library does not model -// (a user-defined chain), which must be preserved verbatim. -func (f *IPTables) modeledFilterChain(ch string) bool { - switch ch { - case "INPUT", "OUTPUT", "FORWARD": - return true - } - return false -} - -// preservedFilterLines returns the indices of modeled-chain (INPUT/OUTPUT/ -// FORWARD) -A lines a rewrite must keep verbatim because GetRules cannot represent -// them as a modeled Rule, so they never appear in the desired set and would -// otherwise be dropped. Two kinds qualify, on the same principle the library -// already applies to a foreign chain (a user-defined chain): a rule the library -// does not model, so it must not be deleted just because it is invisible. -// - A line the rule parser rejects outright — a foreign rule using a match this -// library does not model (e.g. -m recent, -m owner, --tcp-flags). -// - A standalone LOG rule — a non-terminal `-j LOG` line with no action partner -// immediately after it. GetRules coalesces a LOG line with its following -// action line into one logged rule and drops an unpaired one, so it too is -// unmodeled. The pairing mirrors coalesceLoggedRules over the same INPUT/OUTPUT -// sequence parseFilterFile builds. -// -// Rules in other chains are excluded here; the caller preserves those verbatim by -// its own chain check. -func (f *IPTables) preservedFilterLines(lines []string) map[int]bool { - type parsed struct { - idx int - rule *Rule // nil when the line does not parse as a modeled rule. - } - var seq []parsed - inFilter := false - for i, line := range lines { - t := strings.TrimSpace(line) - if strings.HasPrefix(t, "*") { - inFilter = t == "*filter" - continue - } - body := f.ruleLineBody(t) - if !inFilter || !strings.HasPrefix(body, "-A ") { - continue - } - // Only modeled-chain lines are decided here; the caller keeps every other - // chain verbatim, so recording them would double-preserve. - if !f.modeledFilterChain(f.chainOf(body)) { - continue - } - r, err := f.UnmarshalRule(t, FamilyAny) - if err != nil { - r = nil // Unmodeled foreign rule: preserve it. - } - seq = append(seq, parsed{i, r}) - } - preserved := map[int]bool{} - for k, p := range seq { - // A line the parser rejects is a foreign rule the desired set cannot - // reproduce; keep it. - if p.rule == nil { - preserved[p.idx] = true - continue - } - if p.rule.Action != ActionInvalid || !p.rule.Log { - continue - } - // A LOG line paired with the action line that follows it is a coalesced - // logged rule the desired set reproduces, so it is not preserved here. - var next *Rule - if k+1 < len(seq) { - next = seq[k+1].rule - } - if logPartner(p.rule, next) { - continue - } - preserved[p.idx] = true - } - return preserved -} - -// rewriteFilterRules atomically rewrites path so that the *filter table's rule -// (-A) lines are exactly ruleLines, leaving the chain-policy lines, any *nat -// table and all other content untouched. A file with no *filter table gains one. -func (f *IPTables) rewriteFilterRules(path string, ruleLines []string) error { - lines, err := f.readAllLines(path) - if err != nil { - return err - } - preserved := f.preservedFilterLines(lines) - out := make([]string, 0, len(lines)+len(ruleLines)) - inFilter := false - inserted := false - for idx, line := range lines { - t := strings.TrimSpace(line) - switch { - case strings.HasPrefix(t, "*"): - inFilter = t == "*filter" - out = append(out, line) - case inFilter && t == "COMMIT": - if !inserted { - out = append(out, ruleLines...) - inserted = true - } - out = append(out, line) - inFilter = false - case inFilter && strings.HasPrefix(f.ruleLineBody(t), "-A "): - // The library models the INPUT, OUTPUT and FORWARD chains, so the desired - // set can only ever contain those. Drop an existing modeled rule line - // (counter-annotated or not) — the desired set replaces it — but preserve - // a rule in any other chain (a user-defined chain) verbatim, since - // parseFilterFile never captures those chains and dropping them would - // silently delete rules the library does not manage. A modeled-chain line - // the library cannot model (an unmodeled match or a standalone LOG rule) - // is likewise invisible to GetRules and preserved. - if !f.modeledFilterChain(f.chainOf(f.ruleLineBody(t))) { - out = append(out, line) - } else if preserved[idx] { - out = append(out, line) - } - default: - out = append(out, line) - } - } - if !inserted { - out = append(out, "*filter", ":INPUT ACCEPT [0:0]", ":OUTPUT ACCEPT [0:0]", ":FORWARD ACCEPT [0:0]") - out = append(out, ruleLines...) - out = append(out, "COMMIT") - } - return f.writeAllLines(path, out) -} - -// managedNATChain reports whether a nat-table chain is one this backend reads -// and writes (PREROUTING/POSTROUTING). rewriteNATRules replaces the rules in these -// chains and preserves every other nat chain verbatim — including OUTPUT, whose -// locally-generated DNAT the NATRule model cannot represent distinctly (see -// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING. -func (f *IPTables) managedNATChain(chain string) bool { - switch chain { - case "PREROUTING", "POSTROUTING": - return true - } - return false -} - -// rewriteNATRules atomically rewrites path so that the *nat table's rule lines in -// the managed chains are exactly natLines, leaving the chain-policy lines, any -// user-defined nat chain, the *filter table and all other content untouched. A -// file with no *nat table gains one. It is the nat counterpart of -// rewriteFilterRules and is what lets Restore replace managed NAT rules without -// clobbering nat policies or foreign nat rules in other chains. -func (f *IPTables) rewriteNATRules(path string, natLines []string) error { - lines, err := f.readAllLines(path) - if err != nil { - return err - } - out := make([]string, 0, len(lines)+len(natLines)) - inNat := false - inserted := false - for _, line := range lines { - t := strings.TrimSpace(line) - switch { - case strings.HasPrefix(t, "*"): - inNat = t == "*nat" - out = append(out, line) - case inNat && t == "COMMIT": - if !inserted { - out = append(out, natLines...) - inserted = true - } - out = append(out, line) - inNat = false - case inNat && strings.HasPrefix(f.ruleLineBody(t), "-A "): - // Drop an existing rule in a managed chain — the desired set replaces it — - // but preserve a rule in a user-defined nat chain verbatim, mirroring how - // rewriteFilterRules preserves FORWARD/custom-chain rules. - if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) { - out = append(out, line) - } - default: - out = append(out, line) - } - } - if !inserted { - out = append(out, defaultNATSection[:len(defaultNATSection)-1]...) - out = append(out, natLines...) - out = append(out, "COMMIT") - } - return f.writeAllLines(path, out) -} - -// Backup captures the current filter and NAT rules managed by this backend. -func (f *IPTables) Backup(ctx context.Context, zoneName string) (*Backup, error) { - rules, err := f.GetRules(ctx, zoneName) - if err != nil { - return nil, err - } - natRules, err := f.GetNATRules(ctx, zoneName) - if err != nil { - return nil, err - } - // Backup captures the INPUT/OUTPUT/FORWARD filter rules, the nat rules, the - // filter chain default policies and the managed ipsets; Restore replaces exactly - // those on replay, leaving user-defined chains and other tables (which Backup - // does not capture) intact. - backup := &Backup{Rules: rules, NATRules: natRules} - if err := captureBackupState(ctx, f, zoneName, backup); err != nil { - return nil, err - } - return backup, nil -} - -// Restore replaces the managed INPUT/OUTPUT/FORWARD filter rules and the nat rules -// with the contents of a Backup, splicing them into each family's existing save -// file, and re-asserts the captured filter chain policies and ipsets. User-defined -// chains and the *mangle/*raw tables — none of which Backup captures — are left -// untouched. -func (f *IPTables) Restore(ctx context.Context, zoneName string, backup *Backup) error { - if backup == nil { - return fmt.Errorf("backup cannot be nil") - } - - // Recreate the ipsets first so a set-referencing rule (@set) resolves when the - // save files are loaded below. The old rules are still loaded at this point, so - // the sets cannot be removed out from under them; AddAddressSet (ipset -exist) - // creates or repopulates each set idempotently. - if err := restoreBackupSets(ctx, f, backup, false); err != nil { - return err - } - - // Group rules by family. - groupRules := func() map[Family][]*Rule { - m := map[Family][]*Rule{} - for _, r := range backup.Rules { - fam := r.impliedFamily() - if fam == FamilyAny { - m[IPv4] = append(m[IPv4], r) - m[IPv6] = append(m[IPv6], r) - } else { - m[fam] = append(m[fam], r) - } - } - return m - } - - groupNAT := func() map[Family][]*NATRule { - m := map[Family][]*NATRule{} - for _, r := range backup.NATRules { - fam := r.impliedFamily() - if fam == FamilyAny { - m[IPv4] = append(m[IPv4], r) - m[IPv6] = append(m[IPv6], r) - } else { - m[fam] = append(m[fam], r) - } - } - return m - } - - for _, fam := range []Family{IPv4, IPv6} { - path := f.IP4Path - if fam == IPv6 { - path = f.IP6Path - } - - // Marshal only the rule (-A) lines; rewriteFilterRules/rewriteNATRules splice - // them into the existing save file, replacing the managed chains' rules while - // preserving chain-policy lines, user-defined chains, and the *mangle/*raw - // tables that Backup never captures. A from-scratch scaffold would silently - // reset a DROP policy to ACCEPT and delete every unmanaged rule. - var ruleLines []string - for _, r := range groupRules()[fam] { - c := *r - if c.Family == FamilyAny { - c.Family = fam - } - rl, err := f.marshalRuleLines(&c) - if err != nil { - return err - } - ruleLines = append(ruleLines, rl...) - } - - var natLines []string - for _, r := range groupNAT()[fam] { - c := *r - if c.Family == FamilyAny { - c.Family = fam - } - rl, err := f.MarshalNATRule(&c) - if err != nil { - return err - } - natLines = append(natLines, rl) - } - - if err := f.rewriteFilterRules(path, ruleLines); err != nil { - return err - } - if err := f.rewriteNATRules(path, natLines); err != nil { - return err - } - } - - // Re-assert the captured filter chain policies last, so a restore onto a host - // whose default policy differs (e.g. a fresh ACCEPT host) reproduces the backed- - // up policy rather than silently inheriting the current one. - return applyBackupPolicy(ctx, f, zoneName, backup) -} - -// applyRuleFiles runs prepare against each family file the rule applies to, -// staging all temp files first and committing them only once every file has -// been prepared successfully. A FamilyAny rule touches both the IPv4 and IPv6 -// files, so staging up front avoids leaving the rule half-applied if the second -// file fails to prepare. -func (f *IPTables) applyRuleFiles(r *Rule, prepare func(string, *Rule) (*atomicFile, error)) error { - // A DirAny rule fans out into an input row plus its role-swapped output row, - // each marshalled into its own chain within the same family file(s). Recurse per - // concrete-direction half; expandDirections returns a single element for a - // concrete rule, so this never recurses more than once. - if subs := expandDirections(r); len(subs) > 1 { - for _, sub := range subs { - if err := f.applyRuleFiles(sub, prepare); err != nil { - return err - } - } - return nil - } - - // Resolve the family, letting an ICMP/ICMPv6 protocol pin it: `-p icmp` - // belongs only in the IPv4 file and `-p icmpv6` only in the IPv6 file. - family := r.impliedFamily() - var paths []string - if family == IPv4 || family == FamilyAny { - paths = append(paths, f.IP4Path) - } - if family == IPv6 || family == FamilyAny { - paths = append(paths, f.IP6Path) - } - - // Stage every file first. - var staged []*atomicFile - for _, path := range paths { - af, err := prepare(path, r) - if err != nil { - // Discard any temp files already staged. - for _, s := range staged { - s.Abort() - } - return err - } - // A nil handle means no change was needed for this file. - if af != nil { - staged = append(staged, af) - } - } - - // Commit each staged file into place, preserving its mode and ownership. - for _, s := range staged { - if err := s.Commit(); err != nil { - return fmt.Errorf("failed to move new firewall rules into place: %s", err) - } - } - return nil -} - // stageLines writes lines to a fresh atomicFile for path and returns it // uncommitted, so a caller staging several family files can commit them together // once all have been prepared. @@ -2167,92 +1644,97 @@ func (f *IPTables) stageLines(path string, lines []string) (*atomicFile, error) return af, nil } -// Reload reloads the manager to activate new rules. -func (f *IPTables) Reload(ctx context.Context) error { - if err := f.restartUnit(ctx, f.IP4Service); err != nil { - return err +// 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 } - // The Debian layout restores both families from one unit - // (netfilter-persistent.service); restarting it twice is redundant. - if f.IP6Service == f.IP4Service { - return nil - } - return f.restartUnit(ctx, f.IP6Service) -} - -// restartUnit restarts a systemd service and waits for the job to complete. -// The result channel is buffered so the D-Bus goroutine can always deliver -// the job result even if an early return means we never read it. -func (f *IPTables) restartUnit(ctx context.Context, service string) error { - reschan := make(chan string, 1) - _, err := f.Conn.RestartUnitContext(ctx, service, "replace", reschan) - if err != nil { - return fmt.Errorf("failed to restart %s: %s", service, err) - } - if job := <-reschan; job != "done" { - return fmt.Errorf("failed to restart %s: %s", service, "job is not done") - } - return nil -} - -// Close closes the connection to the manager. -func (f *IPTables) Close(ctx context.Context) error { - f.Conn.Close() - return nil -} - -// readAllLines reads every line of an iptables-save file. -func (f *IPTables) readAllLines(path string) ([]string, error) { - fd, err := os.Open(path) + lines, err := f.readAllLines(filePath) if err != nil { return nil, err } - defer func() { _ = fd.Close() }() - var lines []string - scanner := bufio.NewScanner(fd) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - return lines, scanner.Err() -} -// writeAllLines atomically replaces path with the provided lines, preserving -// the existing file's mode and ownership. -func (f *IPTables) writeAllLines(path string, lines []string) error { - af, err := newAtomicFile(path, 0644) + // 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 err + return nil, err } - defer af.Abort() - w := bufio.NewWriter(af) - for _, l := range lines { - _, _ = fmt.Fprintln(w, l) + if removedIdx < 0 { + return nil, nil } - if err := w.Flush(); err != nil { - return err + + without := make([]string, 0, len(lines)-len(extracted)) + for i, l := range lines { + if i >= removedIdx && i < removedIdx+len(extracted) { + continue + } + without = append(without, l) } - return af.Commit() + + // Re-insert at the requested 1-based position. A position past the last rule in + // the chain falls through to the COMMIT branch below, which appends after the + // chain's last rule — so no explicit rule count or clamp is needed here (see + // prepareInsertRuleFile, which relies on the same COMMIT fallback). + // Precompute the 1-based logical position each in-chain line begins over the + // post-removal lines, mirroring GetRules' numbering so the re-inserted rule + // lands at the requested position and never between a LOG line and its action. + chainStarts := f.logicalStarts(f.chainRules(without, expectedChain)) + chainIdx := 0 + + out := make([]string, 0, len(without)+len(extracted)) + filterFound := false + inserted := false + for _, raw := range without { + line := strings.TrimSpace(raw) + + if !filterFound { + if line == "*filter" { + filterFound = true + } + out = append(out, raw) + continue + } + + if !inserted && f.chainOf(f.ruleLineBody(line)) == expectedChain { + pos := 0 + if chainIdx < len(chainStarts) { + pos = chainStarts[chainIdx] + } + chainIdx++ + if pos == position { + out = append(out, extracted...) + inserted = true + } + } + if !inserted && line == "COMMIT" { + out = append(out, extracted...) + inserted = true + } + out = append(out, raw) + } + + if !inserted { + return nil, fmt.Errorf("we were not able to move the rule in the iptables-save file") + } + + return f.stageLines(filePath, out) } -// 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 +// 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) + }) } -// 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) +// 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 @@ -2279,76 +1761,7 @@ func (f *IPTables) parseNATTarget(tok string) (addr string, port uint16) { return tok, 0 } -// natChain returns the nat-table chain a NAT rule belongs in. -func (f *IPTables) natChain(r *NATRule) string { - if r.Kind.isSource() { - return "POSTROUTING" - } - return "PREROUTING" -} - -// MarshalNATRule encodes a NAT rule as an iptables-save rulespec for the nat -// table (e.g. `-A PREROUTING -p tcp --dport 80 -j DNAT --to-destination ...`). -func (f *IPTables) MarshalNATRule(r *NATRule) (string, error) { - if err := r.validate(); err != nil { - return "", err - } - - fam := r.impliedFamily() - parts := []string{"-A", f.natChain(r)} - - if r.Source != "" { - parts = append(parts, f.addrArgs(r.Source, "src")...) - } - if r.Destination != "" { - parts = append(parts, f.addrArgs(r.Destination, "dst")...) - } - - // Interface, bound to the translation direction. - if r.Interface != "" { - if r.Kind.isSource() { - parts = append(parts, "-o", r.Interface) - } else { - parts = append(parts, "-i", r.Interface) - } - } - - if r.Proto != ProtocolAny { - parts = append(parts, "-p", r.Proto.String()) - } - - specs := r.PortSpecs() - if len(specs) == 1 && specs[0].Start == specs[0].End { - parts = append(parts, "-m", r.Proto.String(), "--dport", strconv.FormatUint(uint64(specs[0].Start), 10)) - } else if len(specs) > 0 { - parts = append(parts, "-m", "multiport", "--dports", iptMultiportValue(specs)) - } - - if f.rulePrefix != "" { - quoted, err := f.quoteCommentToken(f.rulePrefix) - if err != nil { - return "", err - } - parts = append(parts, "-m", "comment", "--comment", quoted) - } - - switch r.Kind { - case DNAT: - parts = append(parts, "-j", "DNAT", "--to-destination", natTarget(fam, r.ToAddress, r.ToPort)) - case Redirect: - parts = append(parts, "-j", "REDIRECT", "--to-ports", strconv.FormatUint(uint64(r.ToPort), 10)) - case SNAT: - parts = append(parts, "-j", "SNAT", "--to-source", natTarget(fam, r.ToAddress, r.ToPort)) - case Masquerade: - parts = append(parts, "-j", "MASQUERADE") - default: - return "", fmt.Errorf("invalid nat kind") - } - - return strings.Join(parts, " "), nil -} - -// UnmarshalNATRule decodes an iptables-save nat-table rulespec into a NATRule. +// 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 { @@ -2569,6 +1982,14 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error 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) @@ -2605,27 +2026,122 @@ func (f *IPTables) natRulesInFile(path string) ([]*NATRule, error) { return rules, nil } -// natPaths returns the save files a NAT rule applies to, per its family. -func (f *IPTables) natPaths(r *NATRule) []string { - fam := r.impliedFamily() - var paths []string - if fam == IPv4 || fam == FamilyAny { - paths = append(paths, f.IP4Path) +// GetNATRules returns the existing NAT rules from the zone. +func (f *IPTables) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { + v4, err := f.natRulesInFile(f.IP4Path) + if err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err) } - if fam == IPv6 || fam == FamilyAny { - paths = append(paths, f.IP6Path) + rules = append(rules, v4...) + v6, err := f.natRulesInFile(f.IP6Path) + if err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err) } - return paths + rules = append(rules, v6...) + // Merge families, then renumber per nat chain so a collapsed v4/v6 pair leaves + // no gap in the derived Number sequence. + merged := mergeNATFamilies(rules) + numberNATByChain(merged) + return merged, nil } -// 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", +// 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, @@ -2712,23 +2228,27 @@ func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) e return f.writeAllLines(path, updated) } -// GetNATRules returns the existing NAT rules from the zone. -func (f *IPTables) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { - v4, err := f.natRulesInFile(f.IP4Path) - if err != nil { - return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err) +// 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) } - rules = append(rules, v4...) - v6, err := f.natRulesInFile(f.IP6Path) - if err != nil { - return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err) + if fam == IPv6 || fam == FamilyAny { + paths = append(paths, f.IP6Path) } - rules = append(rules, v6...) - // Merge families, then renumber per nat chain so a collapsed v4/v6 pair leaves - // no gap in the derived Number sequence. - merged := mergeNATFamilies(rules) - numberNATByChain(merged) - return merged, nil + 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. @@ -2745,22 +2265,6 @@ func (f *IPTables) AddNATRule(ctx context.Context, zoneName string, r *NATRule) return nil } -// InsertNATRule inserts a NAT rule at the given 1-based position within its nat -// chain. A non-positive position is treated as 1; a position larger than the -// chain's current rule count appends the rule. -func (f *IPTables) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { - line, err := f.MarshalNATRule(r) - if err != nil { - return err - } - for _, path := range f.natPaths(r) { - if err := f.insertNATFile(path, r, line, position); err != nil { - return err - } - } - return nil -} - // insertNATFile inserts a NAT rule line at the given 1-based position within its // nat chain, creating the table section when the file lacks one. Position counts // only lines in the rule's own chain (PREROUTING or POSTROUTING); a non-positive @@ -2843,6 +2347,22 @@ func (f *IPTables) insertNATFile(path string, r *NATRule, line string, position 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) { @@ -2853,25 +2373,332 @@ func (f *IPTables) RemoveNATRule(ctx context.Context, zoneName string, r *NATRul return nil } -// Capabilities returns the set of features this backend can express. -func (f *IPTables) Capabilities() Capabilities { - return Capabilities{ - Output: true, - Forward: true, - ICMPv6: true, - PortList: true, - ConnState: true, - InterfaceMatch: true, - Logging: true, - RateLimit: true, - ConnLimit: true, - NAT: true, - RuleOrdering: true, - DefaultPolicy: true, - RuleCounters: true, - AddressSets: true, - Comments: true, +// 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 + } + rl, err := f.marshalRuleLines(&c) + if err != nil { + return err + } + ruleLines = append(ruleLines, rl...) + } + + var natLines []string + for _, r := range groupNAT()[fam] { + c := *r + if c.Family == FamilyAny { + c.Family = fam + } + rl, err := f.MarshalNATRule(&c) + if err != nil { + return err + } + natLines = append(natLines, rl) + } + + if err := f.rewriteFilterRules(path, ruleLines); err != nil { + return err + } + if err := f.rewriteNATRules(path, natLines); err != nil { + return err + } + } + + // Re-assert the captured filter chain policies last, so a restore onto a host + // whose default policy differs (e.g. a fresh ACCEPT host) reproduces the backed- + // up policy rather than silently inheriting the current one. + return applyBackupPolicy(ctx, f, zoneName, backup) +} + +// 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 @@ -2915,25 +2742,23 @@ func (f *IPTables) policyFromFile(path string) (*DefaultPolicy, error) { return p, nil } -// parsePolicyLine decodes a `:CHAIN POLICY [counters]` chain declaration. -func (f *IPTables) parsePolicyLine(line string) (chain string, action Action, ok bool) { - t := strings.TrimSpace(line) - if !strings.HasPrefix(t, ":") { - return "", 0, false +// 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 } - fields := strings.Fields(t) - if len(fields) < 2 { - return "", 0, false + v6, err := f.policyFromFile(f.IP6Path) + if err != nil { + return nil, err } - switch fields[1] { - case "ACCEPT": - action = Accept - case "DROP": - action = Drop - default: - return "", 0, false + // 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 strings.TrimPrefix(fields[0], ":"), action, true + return v4, nil } // setPolicyFile rewrites the chain declaration lines in an iptables-save @@ -2986,25 +2811,6 @@ func (f *IPTables) setPolicyFile(path string, policy *DefaultPolicy) error { return f.writeAllLines(path, updated) } -// GetDefaultPolicy returns the default action applied to packets that match no rule. -func (f *IPTables) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { - v4, err := f.policyFromFile(f.IP4Path) - if err != nil { - return nil, err - } - v6, err := f.policyFromFile(f.IP6Path) - if err != nil { - return nil, err - } - // SetDefaultPolicy writes both families identically, so on a host this library - // manages they always agree. A divergence means the IPv4 and IPv6 chain - // policies were set out of band and there is no single policy to report. - if *v4 != *v6 { - return nil, fmt.Errorf("iptables default policy differs between IPv4 (%+v) and IPv6 (%+v)", *v4, *v6) - } - return v4, nil -} - // SetDefaultPolicy sets the default action for the directions named in policy. func (f *IPTables) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { if policy == nil { @@ -3025,16 +2831,6 @@ func (f *IPTables) SetDefaultPolicy(ctx context.Context, zoneName string, policy // --- address sets (ipset) --------------------------------------------------- -// ipsetTypeSpec renders the ipset type keyword and family option for a set. -func (f *IPTables) ipsetTypeSpec(family Family, t SetType) string { - spec := t.String() - fam := "inet" - if family == IPv6 { - fam = "inet6" - } - return spec + " family " + fam -} - // ipsetParseType reads the family and type out of an ipset `create` line's // trailing options. func (f *IPTables) ipsetParseType(fields []string) (Family, SetType) { @@ -3101,6 +2897,16 @@ func (f *IPTables) GetAddressSet(ctx context.Context, name string) (*AddressSet, 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 +} + // 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 == "" { @@ -3122,7 +2928,7 @@ func (f *IPTables) AddAddressSet(ctx context.Context, set *AddressSet) error { return err } } - return nil + return f.persistIPSets(ctx) } // RemoveAddressSet removes an address set by name. @@ -3140,23 +2946,334 @@ func (f *IPTables) RemoveAddressSet(ctx context.Context, name string) error { // notably "Set cannot be destroyed: it is in use by a kernel component" when a // live rule still references the set — is real and must be surfaced rather than // reported as success while the set remains. - if err != nil && strings.Contains(err.Error(), "does not exist") { - return nil + if err != nil && !strings.Contains(err.Error(), "does not exist") { + return err } - 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 { - _, err := runCommand(ctx, "ipset", "add", name, entry, "-exist") - return err + 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") - if err != nil && strings.Contains(err.Error(), "does not 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) +} + +// 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 } - return err + // Ensure the restore unit runs on boot before the rules unit loads. + if f.IPSetService != "" { + if err := f.ensureUnitEnabled(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) +} + +// ensureUnitEnabled enables service persistently when it is installed but not +// already enabled (or static), so a merely-disabled restore unit still runs on +// boot. A unit that is not installed is a no-op — detectIPSetLayout only records +// a service it confirmed present. +func (f *IPTables) ensureUnitEnabled(ctx context.Context, service string) error { + state, present, err := f.unitFileState(ctx, service) + if err != nil { + return fmt.Errorf("error reading state of %s: %s", service, err) + } + if !present { + return nil + } + switch state { + case "enabled", "enabled-runtime", "static": + return nil + } + if _, _, err := f.Conn.EnableUnitFilesContext(ctx, []string{service}, false, true); err != nil { + return fmt.Errorf("failed to enable %s: %s", service, err) + } + // Reload so systemd picks up the new enablement symlinks. + return f.Conn.ReloadContext(ctx) +} + +// restartUnit restarts a systemd service and waits for the job to complete. +// The result channel is buffered so the D-Bus goroutine can always deliver +// the job result even if an early return means we never read it. +func (f *IPTables) restartUnit(ctx context.Context, service string) error { + reschan := make(chan string, 1) + _, err := f.Conn.RestartUnitContext(ctx, service, "replace", reschan) + if err != nil { + return fmt.Errorf("failed to restart %s: %s", service, err) + } + if job := <-reschan; job != "done" { + return fmt.Errorf("failed to restart %s: %s", service, "job is not done") + } + return nil +} + +// Reload reloads the manager to activate new rules. +func (f *IPTables) Reload(ctx context.Context) error { + if err := f.restartUnit(ctx, f.IP4Service); err != nil { + return err + } + + // The Debian layout restores both families from one unit + // (netfilter-persistent.service); restarting it twice is redundant. + if f.IP6Service == f.IP4Service { + return nil + } + return f.restartUnit(ctx, f.IP6Service) +} + +// Close closes the connection to the manager. +func (f *IPTables) Close(ctx context.Context) error { + f.Conn.Close() + 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 before + // the per-family loop, so each half marshals into its own chain. + var expanded []*Rule + for _, r := range rules { + expanded = append(expanded, expandDirections(r)...) + } + rules = expanded + + for _, fam := range []Family{IPv4, IPv6} { + path := f.IP4Path + if fam == IPv6 { + path = f.IP6Path + } + + // Assemble the desired rule set for this family. + var desired []*Rule + if !replace { + existing, err := f.parseFilterFile(path, fam) + if err != nil { + return err + } + desired = append(desired, existing...) + } + for _, r := range rules { + rf := r.impliedFamily() + if rf != FamilyAny && rf != fam { + continue + } + c := *r + if c.Family == FamilyAny { + c.Family = fam + } + dup := false + for _, e := range desired { + if e.EqualBase(&c, true) { + dup = true + break + } + } + if dup { + continue + } + desired = append(desired, &c) + } + + // Marshal the desired rules to save-file lines. + var ruleLines []string + for _, r := range desired { + rl, err := f.marshalRuleLines(r) + if err != nil { + return err + } + ruleLines = append(ruleLines, rl...) + } + + if err := f.rewriteFilterRules(path, ruleLines); err != nil { + return err + } + } + return nil +} + +// 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) +} + +// 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 the +// first rule equal to r (and its LOG partner, if any) via extractRuleLines and +// splice those lines out, so the LOG+action pairing and the *nat/*mangle scoping +// it depends on are defined in exactly one place. +func (f *IPTables) prepareRemoveRuleFile(filePath string, r *Rule) (*atomicFile, error) { + lines, err := f.readAllLines(filePath) + if err != nil { + return nil, err + } + + extracted, removedIdx, err := f.extractRuleLines(lines, r) + if err != nil { + return nil, err + } + if removedIdx < 0 { + // The rule was not present; no change is needed. + return nil, nil + } + + without := make([]string, 0, len(lines)-len(extracted)) + for i, l := range lines { + if i >= removedIdx && i < removedIdx+len(extracted) { + continue + } + without = append(without, l) + } + + return f.stageLines(filePath, without) +} + +// requireUnitEnabled returns IPTablesNoService unless service's UnitFileState +// is "enabled". +func (f *IPTables) requireUnitEnabled(ctx context.Context, service string) error { + prop, err := f.Conn.GetUnitPropertyContext(ctx, service, "UnitFileState") + if err != nil { + return fmt.Errorf("error getting service %s property: %s", service, err) + } + if prop.Value.Value() != "enabled" { + return errors.New(IPTablesNoService) + } + return nil } diff --git a/iptables_linux_test.go b/iptables_linux_test.go index 3427235..1295481 100644 --- a/iptables_linux_test.go +++ b/iptables_linux_test.go @@ -394,6 +394,9 @@ func TestIPTablesLayoutDetection(t *testing.T) { require.True(t, ok, "the RHEL layout should be found when both save files are present") require.Equal(t, "iptables.service", l.ip4Service) require.Equal(t, "ip6tables.service", l.ip6Service) + require.Equal(t, "/etc/sysconfig/ipset", l.ipsetPath, "the RHEL layout persists sets to the ipset-service compat file") + require.Equal(t, "ipset.service", l.ipsetService) + require.Empty(t, l.ipsetPlugin, "the RHEL layout gates on the ipset.service unit, not a plugin file") // RHEL layout: v4 present but v6 missing is an incomplete pair, not a match. root = t.TempDir() @@ -411,6 +414,8 @@ func TestIPTablesLayoutDetection(t *testing.T) { require.True(t, ok, "the Debian layout should be found when both save files are present") require.Equal(t, "netfilter-persistent.service", l.ip4Service) require.Equal(t, l.ip4Service, l.ip6Service, "the Debian layout restores both families from one unit") + require.Equal(t, "/etc/iptables/ipsets", l.ipsetPath, "the Debian layout persists sets alongside the rules files") + require.NotEmpty(t, l.ipsetPlugin, "the Debian layout gates ipset persistence on the netfilter-persistent plugin file") } func TestCombineSplitComment(t *testing.T) { diff --git a/nft_linux.go b/nft_linux.go index f5381f3..7fcaf9f 100644 --- a/nft_linux.go +++ b/nft_linux.go @@ -77,175 +77,32 @@ func (f *NFT) Type() string { return NFTType } +// Capabilities returns the set of features this backend can express. +func (f *NFT) 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; nftables has no interface-to-zone mapping in the model we expose. func (f *NFT) GetZone(ctx context.Context, iface string) (zoneName string, err error) { return "", nil } -// ensureTable creates the private table and its input/output/forward base chains -// if they do not already exist. `add` is idempotent in nftables, so re-running is -// safe. -// -// The chain declarations deliberately omit an explicit `policy`: `add chain` on -// an existing base chain re-asserts the named properties, so writing `policy -// accept` here would revert a default-drop policy a prior SetDefaultPolicy set. -// A base chain created without a policy defaults to accept (the intended initial -// default), and omitting the clause leaves any existing policy untouched. -// SetDefaultPolicy sets the policy separately via setChainPolicy. -func (f *NFT) ensureTable(ctx context.Context) error { - f.mu.Lock() - defer f.mu.Unlock() - if f.ensured { - return nil - } - cmds := [][]string{ - {"add", "table", "inet", f.table}, - {"add", "chain", "inet", f.table, "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "}"}, - {"add", "chain", "inet", f.table, "output", "{", "type", "filter", "hook", "output", "priority", "0", ";", "}"}, - {"add", "chain", "inet", f.table, "forward", "{", "type", "filter", "hook", "forward", "priority", "0", ";", "}"}, - } - for _, c := range cmds { - if _, err := runCommand(ctx, "nft", c...); err != nil { - return fmt.Errorf("failed to set up nftables table %s: %s", f.table, err) - } - } - f.ensured = true - return nil -} - -// nftFilterChains lists the private table's filter base chains, in the order a -// read enumerates them. -var nftFilterChains = []string{"input", "output", "forward"} - -// chainForDirection returns the filter base-chain name a rule of the given -// direction lives in. -func (f *NFT) chainForDirection(d Direction) string { - switch d { - case DirOutput: - return "output" - case DirForward: - return "forward" - } - return "input" -} - -// directionForChain returns the rule direction a filter base-chain name maps -// to (the inverse of chainForDirection). -func (f *NFT) directionForChain(chain string) Direction { - switch chain { - case "output": - return DirOutput - case "forward": - return DirForward - } - return DirInput -} - -// ensureNATChains creates the private table's nat base chains (prerouting for -// destination NAT, postrouting for source NAT) if they do not already exist. It -// is called lazily the first time a NAT rule is written so filter-only use never -// installs nat hooks. `add` is idempotent, so re-running is safe. -func (f *NFT) ensureNATChains(ctx context.Context) error { - if err := f.ensureTable(ctx); err != nil { - return err - } - f.mu.Lock() - defer f.mu.Unlock() - if f.natEnsured { - return nil - } - cmds := [][]string{ - {"add", "chain", "inet", f.table, "prerouting", "{", "type", "nat", "hook", "prerouting", "priority", "dstnat", ";", "policy", "accept", ";", "}"}, - {"add", "chain", "inet", f.table, "postrouting", "{", "type", "nat", "hook", "postrouting", "priority", "srcnat", ";", "policy", "accept", ";", "}"}, - } - for _, c := range cmds { - if _, err := runCommand(ctx, "nft", c...); err != nil { - return fmt.Errorf("failed to set up nftables nat chains for %s: %s", f.table, err) - } - } - f.natEnsured = true - return nil -} - -// addrExpr encodes a source/destination match value: the negation operator -// (`!= `) when the token starts with "!", followed by the value — a bare address -// or, for a non-address token, a named-set reference `@name`. -func (f *NFT) addrExpr(addr string) string { - neg, bare := splitAddrNeg(addr) - op := "" - if neg { - op = "!= " - } - if isSetRef(addr) { - return op + "@" + bare - } - return op + bare -} - -// stripSetRef drops the '@' nft prints before a named-set reference, yielding -// the bare set name the Rule model stores in Source/Destination. -func (f *NFT) stripSetRef(v string) string { - return strings.TrimPrefix(v, "@") -} - -// l3Match returns the nftables layer-3 keyword (ip/ip6) for the given family, -// inferring it from an address when the family is unspecified. -func (f *NFT) l3Match(family Family, addr string) string { - switch family { - case IPv4: - return "ip" - case IPv6: - return "ip6" - } - // Infer from the address for FamilyAny rules. - bare := strings.TrimPrefix(addr, "!") - ip, _, err := net.ParseCIDR(bare) - if err != nil { - ip = net.ParseIP(bare) - } - if ip != nil && ip.To4() == nil { - return "ip6" - } - return "ip" -} - -// l4Proto returns the protocol keyword nftables accepts after `meta l4proto`. -// For ICMPv6 this is the `icmpv6` spelling. nft lists such a rule back with the -// differently-spelled `ipv6-icmp` form, which protoFromToken decodes on read. -func (f *NFT) l4Proto(p Protocol) string { - return p.String() -} - -// protoFromToken decodes a layer-4 protocol token as it appears in `nft list` -// output. nft always normalizes `meta l4proto` back to its protocol name when -// listing, so this backend never needs to decode one — but the numeric IP -// protocol number is accepted too as defensive tolerance for output this backend -// has not itself observed. It returns ProtocolAny for an unrecognized token. -func (f *NFT) protoFromToken(tok string) Protocol { - if p := GetProtocol(tok); p != ProtocolAny { - return p - } - switch tok { - case "1": - return ICMP - case "58": - return ICMPv6 - case "6": - return TCP - case "17": - return UDP - case "132": - return SCTP - case "47": - return GRE - case "50": - return ESP - case "51": - return AH - } - return ProtocolAny -} - // collapseSetSpaces removes the spaces nft inserts inside an anonymous set // literal when it lists a rule (`{ 80, 443 }`), so strings.Fields treats the set // as a single token (`{80,443}`) — the compact form MarshalRule emits and the set @@ -278,39 +135,61 @@ func (f *NFT) collapseSetSpaces(line string) string { return b.String() } -// portExpr renders a destination port match value: a bare port/range for a -// single spec, or an anonymous set `{80,443,1000-2000}` for a list. -func (f *NFT) portExpr(specs []PortRange) string { - if len(specs) == 1 { - return specs[0].String() +// directionForChain returns the rule direction a filter base-chain name maps +// to (the inverse of chainForDirection). +func (f *NFT) directionForChain(chain string) Direction { + switch chain { + case "output": + return DirOutput + case "forward": + return DirForward } - return "{" + FormatPortRanges(specs, ",") + "}" + return DirInput } -// stateExpr renders a ct state match value: a bare name for one state or an -// anonymous set `{established,related}` for several. -func (f *NFT) stateExpr(s ConnState) string { - names := s.Strings() - if len(names) == 1 { - return names[0] +// unquote reverses the quoting MarshalRule applies to a string value (a log +// prefix or comment): a double-quoted token has its surrounding quotes stripped +// literally, not decoded with strconv.Unquote — nft has no backslash-escape +// mechanism, so a Go-style unquote would wrongly reinterpret a literal backslash +// sequence in the value (e.g. "C:\new") as an escape and corrupt it. A token that +// is not a double-quoted string (single-quoted, or bare/older nft) falls back to +// trimQuotes. +func (f *NFT) unquote(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] } - return "{" + strings.Join(names, ",") + "}" + return trimQuotes(s) } -// parseSetTokens strips optional `{ }` braces and splits the comma-separated -// members of an nftables anonymous set (or a single bare value). -func (f *NFT) parseSetTokens(tok string) []string { - tok = strings.TrimSpace(tok) - tok = strings.TrimPrefix(tok, "{") - tok = strings.TrimSuffix(tok, "}") - var out []string - for _, m := range strings.Split(tok, ",") { - m = strings.TrimSpace(m) - if m != "" { - out = append(out, m) +// joinQuoted reassembles a double-quoted value that strings.Fields split on +// its internal whitespace. Given the index of the token that begins the value, +// it returns the unquoted value and the index of the final token consumed. When +// the token at start is not a quoted string it returns that single token +// unquoted. It mirrors the reassembly the comment parser does, so a spaced value +// (a log prefix like "FW DROP: ") round-trips instead of truncating at the space. +func (f *NFT) joinQuoted(tokens []string, start int) (string, int) { + if start >= len(tokens) { + return "", start + } + if !strings.HasPrefix(tokens[start], "\"") { + return f.unquote(tokens[start]), start + } + var parts []string + j := start + for ; j < len(tokens); j++ { + parts = append(parts, tokens[j]) + joined := strings.Join(parts, " ") + // nft's quoting has no escape mechanism, so the first closing quote + // terminates the value. + if len(joined) >= 2 && strings.HasSuffix(joined, "\"") { + break } } - return out + if j >= len(tokens) { + j = len(tokens) - 1 + } + return f.unquote(strings.Join(parts, " ")), j } // parsePorts converts a list of nftables port members (e.g. "80", @@ -363,246 +242,56 @@ func (f *NFT) parseRate(tokens []string, i int) (*RateLimit, int, error) { return rl, i, nil } -// natTarget renders an nftables NAT translation target "[:]", -// bracketing an IPv6 address when a port is present. An empty address yields -// ":" (used by redirect). -func (f *NFT) natTarget(fam Family, addr string, port uint16) string { - if addr == "" { - if port != 0 { - return fmt.Sprintf(":%d", port) +// parseSetTokens strips optional `{ }` braces and splits the comma-separated +// members of an nftables anonymous set (or a single bare value). +func (f *NFT) parseSetTokens(tok string) []string { + tok = strings.TrimSpace(tok) + tok = strings.TrimPrefix(tok, "{") + tok = strings.TrimSuffix(tok, "}") + var out []string + for _, m := range strings.Split(tok, ",") { + m = strings.TrimSpace(m) + if m != "" { + out = append(out, m) } - return "" } - 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) + return out } -// natFamilyKeyword returns the `ip`/`ip6` qualifier nft requires between a -// dnat/snat verb and its `to` in an inet table (e.g. `dnat ip to `). nft -// rejects the unqualified form unless the rule already carries a same-family -// address match, so the write path always emits it and the read path consumes -// it (see UnmarshalNATRule). Redirect and masquerade take no address and need -// no qualifier. -func (f *NFT) natFamilyKeyword(fam Family, addr string) string { - if fam == IPv6 || familyOfAddr(addr) == IPv6 { - return "ip6" +// protoFromToken decodes a layer-4 protocol token as it appears in `nft list` +// output. nft always normalizes `meta l4proto` back to its protocol name when +// listing, so this backend never needs to decode one — but the numeric IP +// protocol number is accepted too as defensive tolerance for output this backend +// has not itself observed. It returns ProtocolAny for an unrecognized token. +func (f *NFT) protoFromToken(tok string) Protocol { + if p := GetProtocol(tok); p != ProtocolAny { + return p } - return "ip" + switch tok { + case "1": + return ICMP + case "58": + return ICMPv6 + case "6": + return TCP + case "17": + return UDP + case "132": + return SCTP + case "47": + return GRE + case "50": + return ESP + case "51": + return AH + } + return ProtocolAny } -// checkQuotable rejects a value containing a double quote. nft's string -// literals (a rule comment, a log prefix) have no escape mechanism at all — a -// `"` unconditionally toggles the quoted state — so there is no way to write one -// containing an embedded quote; nft's own parser errors on the attempt. Rejecting -// it here gives a clear validation error instead of a confusing raw nft syntax -// error from the command itself. -func (f *NFT) checkQuotable(s, field string) error { - if strings.Contains(s, `"`) { - return fmt.Errorf("nftables %s cannot contain a double quote", field) - } - return nil -} - -// MarshalRule encodes a rule as the nftables expression that follows -// `nft add rule inet `. -func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) { - // nftables can only match a port alongside a concrete transport protocol. - if r.PortNeedsConcreteProtocol() { - return "", "", fmt.Errorf("a port requires a tcp or udp protocol") - } - if err := r.checkICMPType(); err != nil { - return "", "", err - } - if err := f.checkQuotable(r.Comment, "comment"); err != nil { - return "", "", err - } - if err := f.checkQuotable(r.LogPrefix, "log prefix"); err != nil { - return "", "", err - } - - chain = "input" - switch r.Direction { - case DirOutput: - chain = "output" - case DirForward: - chain = "forward" - } - - // An input hook can only match the inbound interface; an output hook only the - // outbound one. The forward hook sees both an ingress and an egress interface, - // so it accepts either. - if r.IsOutput() && r.InInterface != "" { - return "", "", fmt.Errorf("an input interface cannot be matched on an output rule") - } - if r.IsInput() && r.OutInterface != "" { - return "", "", fmt.Errorf("an output interface cannot be matched on an input rule") - } - - var parts []string - - // Interface match. - if r.InInterface != "" { - parts = append(parts, fmt.Sprintf("iifname %s", strconv.Quote(r.InInterface))) - } - if r.OutInterface != "" { - parts = append(parts, fmt.Sprintf("oifname %s", strconv.Quote(r.OutInterface))) - } - - // In an inet table the family is only implied when an address match carries - // it (ip/ip6). For a family-specific rule with no address, pin the family - // explicitly so the rule does not silently widen to both families. - if r.Family != FamilyAny && r.Source == "" && r.Destination == "" { - nfproto := "ipv4" - if r.Family == IPv6 { - nfproto = "ipv6" - } - parts = append(parts, "meta nfproto "+nfproto) - } - - // Source address match, honoring negation via a leading '!'. A non-address token - // names a set, referenced as `@name`. - if r.Source != "" { - parts = append(parts, fmt.Sprintf("%s saddr %s", f.l3Match(r.Family, r.Source), f.addrExpr(r.Source))) - } - - // Destination address match. - if r.Destination != "" { - parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(r.Family, r.Destination), f.addrExpr(r.Destination))) - } - - // Protocol / port match. - if r.HasPorts() { - // A concrete protocol is guaranteed by the check above. - parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs()))) - } - srcSpecs := r.SourcePortSpecs() - if len(srcSpecs) > 0 { - parts = append(parts, fmt.Sprintf("%s sport %s", r.Proto.String(), f.portExpr(srcSpecs))) - } - if r.Proto.IsICMP() && r.ICMPType != nil { - // An ICMP type match implies the icmp/icmpv6 protocol. - kw := "icmp" - if r.Proto == ICMPv6 { - kw = "icmpv6" - } - parts = append(parts, fmt.Sprintf("%s type %d", kw, *r.ICMPType)) - } else if r.Proto != ProtocolAny && !r.HasPorts() && len(srcSpecs) == 0 { - parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto)) - } - - // Connection-tracking state match. - if r.State != 0 { - parts = append(parts, "ct state "+f.stateExpr(r.State)) - } - - // Rate limit: the statement matches only while under the rate, so over-rate - // packets fall through to later rules rather than taking this rule's verdict. - if r.RateLimit != nil { - lim := fmt.Sprintf("limit rate %d/%s", r.RateLimit.Rate, r.RateLimit.Unit) - if r.RateLimit.Burst > 0 { - lim += fmt.Sprintf(" burst %d packets", r.RateLimit.Burst) - } - parts = append(parts, lim) - } - - // Connection limit: `ct count over N` matches while the tracked connection - // count exceeds the limit. Per-source counting needs a named meter, which - // this model does not express. - if r.ConnLimit != nil { - if r.ConnLimit.PerSource { - return "", "", fmt.Errorf("nftables per-source connection limiting requires a named meter, unsupported in this model") - } - parts = append(parts, fmt.Sprintf("ct count over %d", r.ConnLimit.Count)) - } - - // Logging, emitted just before the verdict so the packet is logged and then - // the action is applied. - if r.Log { - if r.LogPrefix != "" { - // A plain double-quote wrap, not strconv.Quote: nft has no backslash-escape - // mechanism, so strconv.Quote's Go-style escaping (doubling a literal - // backslash, etc.) would not round-trip through nft's own quoting. The - // embedded-quote case that would need escaping is rejected above. - parts = append(parts, `log prefix "`+r.LogPrefix+`"`) - } else { - parts = append(parts, "log") - } - } - - // A counter so GetRules can report per-rule packet/byte statistics. The - // counter has no effect on matching and is ignored when comparing rules. - parts = append(parts, "counter") - - // Action verb. - switch r.Action { - case Accept: - parts = append(parts, "accept") - case Drop: - parts = append(parts, "drop") - case Reject: - parts = append(parts, "reject") - default: - return "", "", fmt.Errorf("no valid action was provided") - } - - // An optional user comment, stored as an nftables rule comment. It has no - // effect on matching and is ignored when comparing rules. A plain quote wrap, - // not strconv.Quote — see the log-prefix comment above for why. - if r.Comment != "" { - parts = append(parts, `comment "`+r.Comment+`"`) - } - - return chain, strings.Join(parts, " "), nil -} - -// unquote reverses the quoting MarshalRule applies to a string value (a log -// prefix or comment): a double-quoted token has its surrounding quotes stripped -// literally, not decoded with strconv.Unquote — nft has no backslash-escape -// mechanism, so a Go-style unquote would wrongly reinterpret a literal backslash -// sequence in the value (e.g. "C:\new") as an escape and corrupt it. A token that -// is not a double-quoted string (single-quoted, or bare/older nft) falls back to -// trimQuotes. -func (f *NFT) unquote(s string) string { - s = strings.TrimSpace(s) - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { - return s[1 : len(s)-1] - } - return trimQuotes(s) -} - -// joinQuoted reassembles a double-quoted value that strings.Fields split on -// its internal whitespace. Given the index of the token that begins the value, -// it returns the unquoted value and the index of the final token consumed. When -// the token at start is not a quoted string it returns that single token -// unquoted. It mirrors the reassembly the comment parser does, so a spaced value -// (a log prefix like "FW DROP: ") round-trips instead of truncating at the space. -func (f *NFT) joinQuoted(tokens []string, start int) (string, int) { - if start >= len(tokens) { - return "", start - } - if !strings.HasPrefix(tokens[start], "\"") { - return f.unquote(tokens[start]), start - } - var parts []string - j := start - for ; j < len(tokens); j++ { - parts = append(parts, tokens[j]) - joined := strings.Join(parts, " ") - // nft's quoting has no escape mechanism, so the first closing quote - // terminates the value. - if len(joined) >= 2 && strings.HasSuffix(joined, "\"") { - break - } - } - if j >= len(tokens) { - j = len(tokens) - 1 - } - return f.unquote(strings.Join(parts, " ")), j +// stripSetRef drops the '@' nft prints before a named-set reference, yielding +// the bare set name the Rule model stores in Source/Destination. +func (f *NFT) stripSetRef(v string) string { + return strings.TrimPrefix(v, "@") } // UnmarshalRule decodes a single rule line from `nft list` output within the @@ -883,38 +572,6 @@ func (f *NFT) UnmarshalRule(line string, chain string) (r *Rule, handle string, return r, handle, nil } -// listChain returns every parsed rule (with its nftables handle) in a chain. -func (f *NFT) listChain(ctx context.Context, chain string) (rules []*Rule, handles []string, err error) { - out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain) - if err != nil { - // A missing table simply means there are no rules yet. - if strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist") { - return nil, nil, nil - } - return nil, nil, err - } - - for _, line := range out { - line = strings.TrimSpace(line) - // Only rule lines carry a handle; skip table/chain scaffolding. - if line == "" || !strings.Contains(line, "handle ") { - continue - } - rule, handle, perr := f.UnmarshalRule(line, chain) - if perr != nil { - continue - } - // Rules live in this backend's own table; membership in the library's - // private table is what sets HasPrefix, so record the table and flag it - // as carrying the prefix. - rule.table = f.table - rule.HasPrefix = true - rules = append(rules, rule) - handles = append(handles, handle) - } - return rules, handles, nil -} - // headerName extracts the object name from an `nft -a list ruleset` header // line such as `table inet foo { # handle 3` or `chain input { # handle 1`: it // drops the keyword and everything from the opening brace on. nft -a appends @@ -978,6 +635,38 @@ func (f *NFT) listForeignRules(ctx context.Context) ([]*Rule, error) { return rules, nil } +// listChain returns every parsed rule (with its nftables handle) in a chain. +func (f *NFT) listChain(ctx context.Context, chain string) (rules []*Rule, handles []string, err error) { + out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain) + if err != nil { + // A missing table simply means there are no rules yet. + if strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist") { + return nil, nil, nil + } + return nil, nil, err + } + + for _, line := range out { + line = strings.TrimSpace(line) + // Only rule lines carry a handle; skip table/chain scaffolding. + if line == "" || !strings.Contains(line, "handle ") { + continue + } + rule, handle, perr := f.UnmarshalRule(line, chain) + if perr != nil { + continue + } + // Rules live in this backend's own table; membership in the library's + // private table is what sets HasPrefix, so record the table and flag it + // as carrying the prefix. + rule.table = f.table + rule.HasPrefix = true + rules = append(rules, rule) + handles = append(handles, handle) + } + return rules, handles, nil +} + // listOwnRules returns the library's own filter rules from its private table, // with the IPv4/IPv6 pairs merged. A read does not create the table; listChain // returns nothing when the table does not yet exist. @@ -1017,20 +706,282 @@ func (f *NFT) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err return rules, nil } -// AddRule adds a rule to the zone. -func (f *NFT) AddRule(ctx context.Context, zoneName string, r *Rule) error { - return f.insertRule(ctx, zoneName, -1, r) +// addrExpr encodes a source/destination match value: the negation operator +// (`!= `) when the token starts with "!", followed by the value — a bare address +// or, for a non-address token, a named-set reference `@name`. +func (f *NFT) addrExpr(addr string) string { + neg, bare := splitAddrNeg(addr) + op := "" + if neg { + op = "!= " + } + if isSetRef(addr) { + return op + "@" + bare + } + return op + bare } -// InsertRule inserts rule before the given 1-based position. position <= 0 is -// treated as 1 (prepend); a position larger than the current rule count appends -// the rule. Normalizing here keeps insertRule's -1 sentinel reserved for -// AddRule's append path. -func (f *NFT) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { - if position <= 0 { - position = 1 +// checkQuotable rejects a value containing a double quote. nft's string +// literals (a rule comment, a log prefix) have no escape mechanism at all — a +// `"` unconditionally toggles the quoted state — so there is no way to write one +// containing an embedded quote; nft's own parser errors on the attempt. Rejecting +// it here gives a clear validation error instead of a confusing raw nft syntax +// error from the command itself. +func (f *NFT) checkQuotable(s, field string) error { + if strings.Contains(s, `"`) { + return fmt.Errorf("nftables %s cannot contain a double quote", field) + } + return nil +} + +// l3Match returns the nftables layer-3 keyword (ip/ip6) for the given family, +// inferring it from an address when the family is unspecified. +func (f *NFT) l3Match(family Family, addr string) string { + switch family { + case IPv4: + return "ip" + case IPv6: + return "ip6" + } + // Infer from the address for FamilyAny rules. + bare := strings.TrimPrefix(addr, "!") + ip, _, err := net.ParseCIDR(bare) + if err != nil { + ip = net.ParseIP(bare) + } + if ip != nil && ip.To4() == nil { + return "ip6" + } + return "ip" +} + +// l4Proto returns the protocol keyword nftables accepts after `meta l4proto`. +// For ICMPv6 this is the `icmpv6` spelling. nft lists such a rule back with the +// differently-spelled `ipv6-icmp` form, which protoFromToken decodes on read. +func (f *NFT) l4Proto(p Protocol) string { + return p.String() +} + +// portExpr renders a destination port match value: a bare port/range for a +// single spec, or an anonymous set `{80,443,1000-2000}` for a list. +func (f *NFT) portExpr(specs []PortRange) string { + if len(specs) == 1 { + return specs[0].String() + } + return "{" + FormatPortRanges(specs, ",") + "}" +} + +// stateExpr renders a ct state match value: a bare name for one state or an +// anonymous set `{established,related}` for several. +func (f *NFT) stateExpr(s ConnState) string { + names := s.Strings() + if len(names) == 1 { + return names[0] + } + return "{" + strings.Join(names, ",") + "}" +} + +// MarshalRule encodes a rule as the nftables expression that follows +// `nft add rule inet
`. +func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) { + // nftables can only match a port alongside a concrete transport protocol. + if r.PortNeedsConcreteProtocol() { + return "", "", fmt.Errorf("a port requires a tcp or udp protocol") + } + if err := r.checkICMPType(); err != nil { + return "", "", err + } + if err := f.checkQuotable(r.Comment, "comment"); err != nil { + return "", "", err + } + if err := f.checkQuotable(r.LogPrefix, "log prefix"); err != nil { + return "", "", err + } + + chain = "input" + switch r.Direction { + case DirOutput: + chain = "output" + case DirForward: + chain = "forward" + } + + // An input hook can only match the inbound interface; an output hook only the + // outbound one. The forward hook sees both an ingress and an egress interface, + // so it accepts either. + if r.IsOutput() && r.InInterface != "" { + return "", "", fmt.Errorf("an input interface cannot be matched on an output rule") + } + if r.IsInput() && r.OutInterface != "" { + return "", "", fmt.Errorf("an output interface cannot be matched on an input rule") + } + + var parts []string + + // Interface match. + if r.InInterface != "" { + parts = append(parts, fmt.Sprintf("iifname %s", strconv.Quote(r.InInterface))) + } + if r.OutInterface != "" { + parts = append(parts, fmt.Sprintf("oifname %s", strconv.Quote(r.OutInterface))) + } + + // In an inet table the family is only implied when an address match carries + // it (ip/ip6). For a family-specific rule with no address, pin the family + // explicitly so the rule does not silently widen to both families. + if r.Family != FamilyAny && r.Source == "" && r.Destination == "" { + nfproto := "ipv4" + if r.Family == IPv6 { + nfproto = "ipv6" + } + parts = append(parts, "meta nfproto "+nfproto) + } + + // Source address match, honoring negation via a leading '!'. A non-address token + // names a set, referenced as `@name`. + if r.Source != "" { + parts = append(parts, fmt.Sprintf("%s saddr %s", f.l3Match(r.Family, r.Source), f.addrExpr(r.Source))) + } + + // Destination address match. + if r.Destination != "" { + parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(r.Family, r.Destination), f.addrExpr(r.Destination))) + } + + // Protocol / port match. + if r.HasPorts() { + // A concrete protocol is guaranteed by the check above. + parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs()))) + } + srcSpecs := r.SourcePortSpecs() + if len(srcSpecs) > 0 { + parts = append(parts, fmt.Sprintf("%s sport %s", r.Proto.String(), f.portExpr(srcSpecs))) + } + if r.Proto.IsICMP() && r.ICMPType != nil { + // An ICMP type match implies the icmp/icmpv6 protocol. + kw := "icmp" + if r.Proto == ICMPv6 { + kw = "icmpv6" + } + parts = append(parts, fmt.Sprintf("%s type %d", kw, *r.ICMPType)) + } else if r.Proto != ProtocolAny && !r.HasPorts() && len(srcSpecs) == 0 { + parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto)) + } + + // Connection-tracking state match. + if r.State != 0 { + parts = append(parts, "ct state "+f.stateExpr(r.State)) + } + + // Rate limit: the statement matches only while under the rate, so over-rate + // packets fall through to later rules rather than taking this rule's verdict. + if r.RateLimit != nil { + lim := fmt.Sprintf("limit rate %d/%s", r.RateLimit.Rate, r.RateLimit.Unit) + if r.RateLimit.Burst > 0 { + lim += fmt.Sprintf(" burst %d packets", r.RateLimit.Burst) + } + parts = append(parts, lim) + } + + // Connection limit: `ct count over N` matches while the tracked connection + // count exceeds the limit. Per-source counting needs a named meter, which + // this model does not express. + if r.ConnLimit != nil { + if r.ConnLimit.PerSource { + return "", "", fmt.Errorf("nftables per-source connection limiting requires a named meter, unsupported in this model") + } + parts = append(parts, fmt.Sprintf("ct count over %d", r.ConnLimit.Count)) + } + + // Logging, emitted just before the verdict so the packet is logged and then + // the action is applied. + if r.Log { + if r.LogPrefix != "" { + // A plain double-quote wrap, not strconv.Quote: nft has no backslash-escape + // mechanism, so strconv.Quote's Go-style escaping (doubling a literal + // backslash, etc.) would not round-trip through nft's own quoting. The + // embedded-quote case that would need escaping is rejected above. + parts = append(parts, `log prefix "`+r.LogPrefix+`"`) + } else { + parts = append(parts, "log") + } + } + + // A counter so GetRules can report per-rule packet/byte statistics. The + // counter has no effect on matching and is ignored when comparing rules. + parts = append(parts, "counter") + + // Action verb. + switch r.Action { + case Accept: + parts = append(parts, "accept") + case Drop: + parts = append(parts, "drop") + case Reject: + parts = append(parts, "reject") + default: + return "", "", fmt.Errorf("no valid action was provided") + } + + // An optional user comment, stored as an nftables rule comment. It has no + // effect on matching and is ignored when comparing rules. A plain quote wrap, + // not strconv.Quote — see the log-prefix comment above for why. + if r.Comment != "" { + parts = append(parts, `comment "`+r.Comment+`"`) + } + + return chain, strings.Join(parts, " "), nil +} + +// ensureTable creates the private table and its input/output/forward base chains +// if they do not already exist. `add` is idempotent in nftables, so re-running is +// safe. +// +// The chain declarations deliberately omit an explicit `policy`: `add chain` on +// an existing base chain re-asserts the named properties, so writing `policy +// accept` here would revert a default-drop policy a prior SetDefaultPolicy set. +// A base chain created without a policy defaults to accept (the intended initial +// default), and omitting the clause leaves any existing policy untouched. +func (f *NFT) ensureTable(ctx context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.ensured { + return nil + } + cmds := [][]string{ + {"add", "table", "inet", f.table}, + {"add", "chain", "inet", f.table, "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "}"}, + {"add", "chain", "inet", f.table, "output", "{", "type", "filter", "hook", "output", "priority", "0", ";", "}"}, + {"add", "chain", "inet", f.table, "forward", "{", "type", "filter", "hook", "forward", "priority", "0", ";", "}"}, + } + for _, c := range cmds { + if _, err := runCommand(ctx, "nft", c...); err != nil { + return fmt.Errorf("failed to set up nftables table %s: %s", f.table, err) + } + } + f.ensured = true + return nil +} + +// nftFilterChains lists the private table's filter base chains, in the order a +// read enumerates them. +var nftFilterChains = []string{"input", "output", "forward"} + +// placeArgs builds the nft command that places a rule expression at 0-based +// index insPos in a chain currently holding n rules. nft can insert before an +// existing rule (`insert ... index k`, k in [0,n-1]) or prepend (`insert` with no +// index), but it has no insert-at-end form, so an index at or past the end must +// append with `add rule`. +func (f *NFT) placeArgs(chain, expr string, insPos, n int) []string { + fields := strings.Fields(expr) + switch { + case insPos >= n: + return append([]string{"add", "rule", "inet", f.table, chain}, fields...) + case insPos <= 0: + return append([]string{"insert", "rule", "inet", f.table, chain}, fields...) + default: + return append([]string{"insert", "rule", "inet", f.table, chain, "index", strconv.Itoa(insPos)}, fields...) } - return f.insertRule(ctx, zoneName, position, r) } // ruleExists reports whether existing already contains a rule matching r. @@ -1043,16 +994,6 @@ func (f *NFT) ruleExists(existing []*Rule, r *Rule) bool { return false } -// natRuleExists is ruleExists for NAT rules. -func (f *NFT) natRuleExists(existing []*NATRule, r *NATRule) bool { - for _, e := range existing { - if e.EqualForDedup(r) { - return true - } - } - return false -} - func (f *NFT) insertRule(ctx context.Context, zoneName string, position int, r *Rule) error { if err := f.ensureTable(ctx); err != nil { return err @@ -1097,21 +1038,31 @@ func (f *NFT) insertRule(ctx context.Context, zoneName string, position int, r * return err } -// placeArgs builds the nft command that places a rule expression at 0-based -// index insPos in a chain currently holding n rules. nft can insert before an -// existing rule (`insert ... index k`, k in [0,n-1]) or prepend (`insert` with no -// index), but it has no insert-at-end form, so an index at or past the end must -// append with `add rule`. -func (f *NFT) placeArgs(chain, expr string, insPos, n int) []string { - fields := strings.Fields(expr) - switch { - case insPos >= n: - return append([]string{"add", "rule", "inet", f.table, chain}, fields...) - case insPos <= 0: - return append([]string{"insert", "rule", "inet", f.table, chain}, fields...) - default: - return append([]string{"insert", "rule", "inet", f.table, chain, "index", strconv.Itoa(insPos)}, fields...) +// AddRule adds a rule to the zone. +func (f *NFT) AddRule(ctx context.Context, zoneName string, r *Rule) error { + return f.insertRule(ctx, zoneName, -1, r) +} + +// InsertRule inserts rule before the given 1-based position. position <= 0 is +// treated as 1 (prepend); a position larger than the current rule count appends +// the rule. Normalizing here keeps insertRule's -1 sentinel reserved for +func (f *NFT) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + if position <= 0 { + position = 1 } + return f.insertRule(ctx, zoneName, position, r) +} + +// chainForDirection returns the filter base-chain name a rule of the given +// direction lives in. +func (f *NFT) chainForDirection(d Direction) string { + switch d { + case DirOutput: + return "output" + case DirForward: + return "forward" + } + return "input" } // MoveRule moves an existing rule to the given 1-based position. @@ -1276,197 +1227,6 @@ func (f *NFT) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { return nil } -// AddRulesBatch adds every rule in a single `nft -f` transaction rather than one -// `nft add rule` invocation per rule. Rules that already exist are skipped. The -// whole batch applies atomically. It implements RuleBatcher. -func (f *NFT) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { - if err := f.ensureTable(ctx); err != nil { - return err - } - - // Snapshot each chain so duplicates (existing or within the batch) are - // skipped, mirroring AddRule. - existing := map[string][]*Rule{} - for _, chain := range nftFilterChains { - rs, _, err := f.listChain(ctx, chain) - if err != nil { - return err - } - existing[chain] = rs - } - - var script strings.Builder - n := 0 - for _, top := range rules { - // A DirAny rule fans out into an input row plus its swapped output row. - for _, r := range expandDirections(top) { - chain, expr, err := f.MarshalRule(r) - if err != nil { - return err - } - if f.ruleExists(existing[chain], r) { - continue - } - fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr) - existing[chain] = append(existing[chain], r) - n++ - } - } - if n == 0 { - return nil - } - _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-") - return err -} - -// ReplaceRulesBatch atomically flushes the private table's input and output -// chains and re-adds exactly rules, all in one `nft -f` transaction. Chain hooks -// and policies are preserved (flush chain removes only rules). It implements -// RuleBatcher. -func (f *NFT) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { - if err := f.ensureTable(ctx); err != nil { - return err - } - - var script strings.Builder - fmt.Fprintf(&script, "flush chain inet %s input\n", f.table) - fmt.Fprintf(&script, "flush chain inet %s output\n", f.table) - for _, top := range rules { - // A DirAny rule fans out into an input row plus its swapped output row. - for _, r := range expandDirections(top) { - chain, expr, err := f.MarshalRule(r) - if err != nil { - return err - } - fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr) - } - } - _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-") - return err -} - -// Backup captures the filter and NAT rules in this backend's private table. -func (f *NFT) Backup(ctx context.Context, zoneName string) (*Backup, error) { - // Read the private table directly rather than GetRules: Restore flushes and - // refills only this table, so the backup must not pull in rules from foreign - // tables (they would be re-added into the wrong table on Restore). - rules, err := f.listOwnRules(ctx) - if err != nil { - return nil, err - } - natRules, err := f.listOwnNATRules(ctx) - if err != nil { - return nil, err - } - backup := &Backup{Rules: rules, NATRules: natRules} - if err := captureBackupState(ctx, f, zoneName, backup); err != nil { - return nil, err - } - return backup, nil -} - -// Restore replaces the managed rules with the contents of a Backup. -func (f *NFT) Restore(ctx context.Context, zoneName string, backup *Backup) error { - if backup == nil { - return fmt.Errorf("backup cannot be nil") - } - if err := f.ensureTable(ctx); err != nil { - return err - } - if err := f.ensureNATChains(ctx); err != nil { - return err - } - - // Flush the private table, then re-add all rules. - if _, err := runCommand(ctx, "nft", "flush", "table", "inet", f.table); err != nil { - return err - } - - // Recreate the sets on a clean slate before the rules that reference them. The - // flush above cleared every rule in the table, so no rule holds a set reference - // and each set can be removed and rebuilt; the clean rebuild is required because - // nft's AddAddressSet is a no-op on an existing set and would not otherwise - // restore a flushed set's elements. - if err := restoreBackupSets(ctx, f, backup, true); err != nil { - return err - } - - for _, r := range backup.Rules { - if err := f.AddRule(ctx, zoneName, r); err != nil { - return err - } - } - for _, r := range backup.NATRules { - if err := f.AddNATRule(ctx, zoneName, r); err != nil { - return err - } - } - return applyBackupPolicy(ctx, f, zoneName, backup) -} - -// MarshalNATRule encodes a NAT rule as the nftables expression that follows -// `nft add rule inet
`, returning the chain (prerouting for -// destination NAT, postrouting for source NAT) and the expression. -func (f *NFT) MarshalNATRule(r *NATRule) (chain string, expr string, err error) { - if err := r.validate(); err != nil { - return "", "", err - } - - fam := r.impliedFamily() - var parts []string - - // Interface, bound to the NAT direction: outbound for source NAT, inbound - // for destination NAT. - if r.Interface != "" { - if r.Kind.isSource() { - parts = append(parts, "oifname "+strconv.Quote(r.Interface)) - } else { - parts = append(parts, "iifname "+strconv.Quote(r.Interface)) - } - } - - // Pin the family when no address carries it. - if fam != FamilyAny && r.Source == "" && r.Destination == "" { - nfproto := "ipv4" - if fam == IPv6 { - nfproto = "ipv6" - } - parts = append(parts, "meta nfproto "+nfproto) - } - - if r.Source != "" { - parts = append(parts, fmt.Sprintf("%s saddr %s", f.l3Match(fam, r.Source), f.addrExpr(r.Source))) - } - if r.Destination != "" { - parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(fam, r.Destination), f.addrExpr(r.Destination))) - } - - if r.HasPorts() { - parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs()))) - } else if r.Proto != ProtocolAny { - parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto)) - } - - switch r.Kind { - case DNAT: - chain = "prerouting" - parts = append(parts, "dnat "+f.natFamilyKeyword(fam, r.ToAddress)+" to "+f.natTarget(fam, r.ToAddress, r.ToPort)) - case Redirect: - chain = "prerouting" - parts = append(parts, "redirect to "+f.natTarget(fam, "", r.ToPort)) - case SNAT: - chain = "postrouting" - parts = append(parts, "snat "+f.natFamilyKeyword(fam, r.ToAddress)+" to "+f.natTarget(fam, r.ToAddress, 0)) - case Masquerade: - chain = "postrouting" - parts = append(parts, "masquerade") - default: - return "", "", fmt.Errorf("invalid nat kind") - } - - return chain, strings.Join(parts, " "), nil -} - // parseNATTarget parses an nftables NAT target token ("addr", "addr:port", // "[v6]:port" or ":port") into its address and port. func (f *NFT) parseNATTarget(tok string) (addr string, port uint16, err error) { @@ -1655,34 +1415,6 @@ func (f *NFT) UnmarshalNATRule(line string, chain string) (r *NATRule, handle st return r, handle, nil } -// listNATChain returns every parsed NAT rule (with its handle) in a chain. -func (f *NFT) listNATChain(ctx context.Context, chain string) (rules []*NATRule, handles []string, err error) { - out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain) - if err != nil { - if strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist") { - return nil, nil, nil - } - return nil, nil, err - } - for _, line := range out { - line = strings.TrimSpace(line) - if line == "" || !strings.Contains(line, "handle ") { - continue - } - rule, handle, perr := f.UnmarshalNATRule(line, chain) - if perr != nil { - continue - } - // NAT rules live in this backend's own table; membership is what sets - // HasPrefix, so record the table and flag it as carrying the prefix. - rule.table = f.table - rule.HasPrefix = true - rules = append(rules, rule) - handles = append(handles, handle) - } - return rules, handles, nil -} - // listForeignNATRules walks the entire nftables ruleset and returns best-effort // NAT rules that live outside this backend's own inet table. Like listForeignRules // it skips any line it cannot parse. @@ -1723,6 +1455,34 @@ func (f *NFT) listForeignNATRules(ctx context.Context) ([]*NATRule, error) { return rules, nil } +// listNATChain returns every parsed NAT rule (with its handle) in a chain. +func (f *NFT) listNATChain(ctx context.Context, chain string) (rules []*NATRule, handles []string, err error) { + out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain) + if err != nil { + if strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist") { + return nil, nil, nil + } + return nil, nil, err + } + for _, line := range out { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, "handle ") { + continue + } + rule, handle, perr := f.UnmarshalNATRule(line, chain) + if perr != nil { + continue + } + // NAT rules live in this backend's own table; membership is what sets + // HasPrefix, so record the table and flag it as carrying the prefix. + rule.table = f.table + rule.HasPrefix = true + rules = append(rules, rule) + handles = append(handles, handle) + } + return rules, handles, nil +} + // listOwnNATRules returns the library's own NAT rules from its private table, // with the IPv4/IPv6 pairs merged. func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) { @@ -1756,6 +1516,137 @@ func (f *NFT) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRul return rules, nil } +// natFamilyKeyword returns the `ip`/`ip6` qualifier nft requires between a +// dnat/snat verb and its `to` in an inet table (e.g. `dnat ip to `). nft +// rejects the unqualified form unless the rule already carries a same-family +// address match, so the write path always emits it and the read path consumes +// it (see UnmarshalNATRule). Redirect and masquerade take no address and need +// no qualifier. +func (f *NFT) natFamilyKeyword(fam Family, addr string) string { + if fam == IPv6 || familyOfAddr(addr) == IPv6 { + return "ip6" + } + return "ip" +} + +// natTarget renders an nftables NAT translation target "[:]", +// bracketing an IPv6 address when a port is present. An empty address yields +// ":" (used by redirect). +func (f *NFT) natTarget(fam Family, addr string, port uint16) string { + if addr == "" { + if port != 0 { + return fmt.Sprintf(":%d", port) + } + return "" + } + 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 the nftables expression that follows +// `nft add rule inet
`, returning the chain (prerouting for +// destination NAT, postrouting for source NAT) and the expression. +func (f *NFT) MarshalNATRule(r *NATRule) (chain string, expr string, err error) { + if err := r.validate(); err != nil { + return "", "", err + } + + fam := r.impliedFamily() + var parts []string + + // Interface, bound to the NAT direction: outbound for source NAT, inbound + // for destination NAT. + if r.Interface != "" { + if r.Kind.isSource() { + parts = append(parts, "oifname "+strconv.Quote(r.Interface)) + } else { + parts = append(parts, "iifname "+strconv.Quote(r.Interface)) + } + } + + // Pin the family when no address carries it. + if fam != FamilyAny && r.Source == "" && r.Destination == "" { + nfproto := "ipv4" + if fam == IPv6 { + nfproto = "ipv6" + } + parts = append(parts, "meta nfproto "+nfproto) + } + + if r.Source != "" { + parts = append(parts, fmt.Sprintf("%s saddr %s", f.l3Match(fam, r.Source), f.addrExpr(r.Source))) + } + if r.Destination != "" { + parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(fam, r.Destination), f.addrExpr(r.Destination))) + } + + if r.HasPorts() { + parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs()))) + } else if r.Proto != ProtocolAny { + parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto)) + } + + switch r.Kind { + case DNAT: + chain = "prerouting" + parts = append(parts, "dnat "+f.natFamilyKeyword(fam, r.ToAddress)+" to "+f.natTarget(fam, r.ToAddress, r.ToPort)) + case Redirect: + chain = "prerouting" + parts = append(parts, "redirect to "+f.natTarget(fam, "", r.ToPort)) + case SNAT: + chain = "postrouting" + parts = append(parts, "snat "+f.natFamilyKeyword(fam, r.ToAddress)+" to "+f.natTarget(fam, r.ToAddress, 0)) + case Masquerade: + chain = "postrouting" + parts = append(parts, "masquerade") + default: + return "", "", fmt.Errorf("invalid nat kind") + } + + return chain, strings.Join(parts, " "), nil +} + +// ensureNATChains creates the private table's nat base chains (prerouting for +// destination NAT, postrouting for source NAT) if they do not already exist. It +// is called lazily the first time a NAT rule is written so filter-only use never +// installs nat hooks. `add` is idempotent, so re-running is safe. +func (f *NFT) ensureNATChains(ctx context.Context) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + f.mu.Lock() + defer f.mu.Unlock() + if f.natEnsured { + return nil + } + cmds := [][]string{ + {"add", "chain", "inet", f.table, "prerouting", "{", "type", "nat", "hook", "prerouting", "priority", "dstnat", ";", "policy", "accept", ";", "}"}, + {"add", "chain", "inet", f.table, "postrouting", "{", "type", "nat", "hook", "postrouting", "priority", "srcnat", ";", "policy", "accept", ";", "}"}, + } + for _, c := range cmds { + if _, err := runCommand(ctx, "nft", c...); err != nil { + return fmt.Errorf("failed to set up nftables nat chains for %s: %s", f.table, err) + } + } + f.natEnsured = true + return nil +} + +// natRuleExists is ruleExists for NAT rules. +func (f *NFT) natRuleExists(existing []*NATRule, r *NATRule) bool { + for _, e := range existing { + if e.EqualForDedup(r) { + return true + } + } + return false +} + // AddNATRule adds a NAT rule to the zone. func (f *NFT) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { if err := f.ensureNATChains(ctx); err != nil { @@ -1840,35 +1731,63 @@ func (f *NFT) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) er return nil } -// Reload is a no-op; nftables applies changes immediately, so there is nothing to reload. -func (f *NFT) Reload(ctx context.Context) error { - return nil -} - -// Close closes the connection to the manager. -func (f *NFT) Close(ctx context.Context) error { - return nil -} - -// Capabilities returns the set of features this backend can express. -func (f *NFT) 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, +// Backup captures the filter and NAT rules in this backend's private table. +func (f *NFT) Backup(ctx context.Context, zoneName string) (*Backup, error) { + // Read the private table directly rather than GetRules: Restore flushes and + // refills only this table, so the backup must not pull in rules from foreign + // tables (they would be re-added into the wrong table on Restore). + rules, err := f.listOwnRules(ctx) + if err != nil { + return nil, err } + natRules, err := f.listOwnNATRules(ctx) + if err != nil { + return nil, err + } + backup := &Backup{Rules: rules, NATRules: natRules} + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *NFT) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + if err := f.ensureTable(ctx); err != nil { + return err + } + if err := f.ensureNATChains(ctx); err != nil { + return err + } + + // Flush the private table, then re-add all rules. + if _, err := runCommand(ctx, "nft", "flush", "table", "inet", f.table); err != nil { + return err + } + + // Recreate the sets on a clean slate before the rules that reference them. The + // flush above cleared every rule in the table, so no rule holds a set reference + // and each set can be removed and rebuilt; the clean rebuild is required because + // nft's AddAddressSet is a no-op on an existing set and would not otherwise + // restore a flushed set's elements. + if err := restoreBackupSets(ctx, f, backup, true); err != nil { + return err + } + + for _, r := range backup.Rules { + if err := f.AddRule(ctx, zoneName, r); err != nil { + return err + } + } + for _, r := range backup.NATRules { + if err := f.AddNATRule(ctx, zoneName, r); err != nil { + return err + } + } + return applyBackupPolicy(ctx, f, zoneName, backup) } // chainPolicy reads the policy of one of this backend's base chains. It returns @@ -1923,7 +1842,7 @@ func (f *NFT) setChainPolicy(ctx context.Context, chain string, action Action) e return err } -// SetDefaultPolicy sets the default action for the directions named in policy. +// SetDefaultPolicy sets the policy separately via setChainPolicy. func (f *NFT) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { if policy == nil { return fmt.Errorf("policy cannot be nil") @@ -1983,35 +1902,6 @@ func (f *NFT) decodeSets(out []string) []*nftSetJSON { return sets } -// nftSetType maps a set type/family to the nftables set spec that follows -// `nft add set inet
`: a `{ type ... ; [flags interval ;] }` -// expression. It rejects a combination nftables cannot express in an inet set. -func (f *NFT) setSpec(family Family, t SetType) (string, error) { - if family == FamilyAny { - family = IPv4 - } - var addrType string - switch family { - case IPv4: - addrType = "ipv4_addr" - case IPv6: - addrType = "ipv6_addr" - default: - return "", fmt.Errorf("a set requires a concrete ip family: %w", ErrUnsupportedSet) - } - spec := "{ type " + addrType + " ;" - if t == SetHashNet { - spec += " flags interval ;" - } - return spec + " }", nil -} - -// setEntries renders the set's entries as an nftables element expression, e.g. -// `{ 1.2.3.4, 10.0.0.0/8 }`. -func (f *NFT) setEntries(entries []string) string { - return "{ " + strings.Join(entries, ", ") + " }" -} - // decodeElem decodes one JSON element of an nft set into its string form. A // scalar becomes the address/CIDR string; a {"prefix":{"addr":..,"len":..}} // object becomes a CIDR; a {"range":[lo,hi]} object (which an interval set uses @@ -2047,36 +1937,6 @@ func (f *NFT) decodeElem(raw json.RawMessage) string { return "" } -// GetAddressSets returns the address sets managed by this backend. -func (f *NFT) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { - if err := f.ensureTable(ctx); err != nil { - return nil, err - } - // `nft list sets` accepts a family (inet) but not a table name, so list every - // inet set and keep the ones in our table. It exits 0 with an empty listing - // when there are no sets, so any error here is a genuine failure. - out, err := runCommand(ctx, "nft", "-j", "list", "sets", "inet") - if err != nil { - return nil, err - } - sets := f.decodeSets(out) - result := make([]*AddressSet, 0, len(sets)) - for _, s := range sets { - if s.Table != f.table { - continue - } - detail, err := f.getAddressSet(ctx, s.Name) - if err != nil { - return nil, err - } - if detail == nil { - continue - } - result = append(result, detail) - } - return result, nil -} - // getAddressSet reads a single nftables set as an AddressSet, or nil if it does // not exist. func (f *NFT) getAddressSet(ctx context.Context, name string) (*AddressSet, error) { @@ -2114,6 +1974,36 @@ func (f *NFT) getAddressSet(ctx context.Context, name string) (*AddressSet, erro return set, nil } +// GetAddressSets returns the address sets managed by this backend. +func (f *NFT) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + if err := f.ensureTable(ctx); err != nil { + return nil, err + } + // `nft list sets` accepts a family (inet) but not a table name, so list every + // inet set and keep the ones in our table. It exits 0 with an empty listing + // when there are no sets, so any error here is a genuine failure. + out, err := runCommand(ctx, "nft", "-j", "list", "sets", "inet") + if err != nil { + return nil, err + } + sets := f.decodeSets(out) + result := make([]*AddressSet, 0, len(sets)) + for _, s := range sets { + if s.Table != f.table { + continue + } + detail, err := f.getAddressSet(ctx, s.Name) + if err != nil { + return nil, err + } + if detail == nil { + continue + } + result = append(result, detail) + } + return result, nil +} + // GetAddressSet returns a single address set by name, or an error if it does not exist. func (f *NFT) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { if err := f.ensureTable(ctx); err != nil { @@ -2129,6 +2019,12 @@ func (f *NFT) GetAddressSet(ctx context.Context, name string) (*AddressSet, erro return set, nil } +// setEntries renders the set's entries as an nftables element expression, e.g. +// `{ 1.2.3.4, 10.0.0.0/8 }`. +func (f *NFT) setEntries(entries []string) string { + return "{ " + strings.Join(entries, ", ") + " }" +} + // setMatches reports whether an existing set's definition matches a requested // family/type. AddAddressSet uses this to tell a harmless re-add of an identical, // already-existing set (safe to treat as success — `nft add set` is idempotent @@ -2147,6 +2043,26 @@ func (f *NFT) setMatches(existing *AddressSet, wantFamily Family, wantType SetTy return existing.Family == wantFamily && existing.Type == wantType } +func (f *NFT) setSpec(family Family, t SetType) (string, error) { + if family == FamilyAny { + family = IPv4 + } + var addrType string + switch family { + case IPv4: + addrType = "ipv4_addr" + case IPv6: + addrType = "ipv6_addr" + default: + return "", fmt.Errorf("a set requires a concrete ip family: %w", ErrUnsupportedSet) + } + spec := "{ type " + addrType + " ;" + if t == SetHashNet { + spec += " flags interval ;" + } + return spec + " }", nil +} + // AddAddressSet creates an address set. Adding a set that already exists (by name) is a no-op. func (f *NFT) AddAddressSet(ctx context.Context, set *AddressSet) error { if set == nil || set.Name == "" { @@ -2220,3 +2136,82 @@ func (f *NFT) RemoveAddressSetEntry(ctx context.Context, name, entry string) err _, err := runCommand(ctx, "nft", args...) return err } + +// Reload is a no-op; nftables applies changes immediately, so there is nothing to reload. +func (f *NFT) Reload(ctx context.Context) error { + return nil +} + +// Close closes the connection to the manager. +func (f *NFT) Close(ctx context.Context) error { + return nil +} + +// AddRulesBatch adds every rule in a single `nft -f` transaction rather than one +// `nft add rule` invocation per rule. Rules that already exist are skipped. The +// whole batch applies atomically. It implements RuleBatcher. +func (f *NFT) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + + // Snapshot each chain so duplicates (existing or within the batch) are + // skipped, mirroring AddRule. + existing := map[string][]*Rule{} + for _, chain := range nftFilterChains { + rs, _, err := f.listChain(ctx, chain) + if err != nil { + return err + } + existing[chain] = rs + } + + var script strings.Builder + n := 0 + for _, top := range rules { + // A DirAny rule fans out into an input row plus its swapped output row. + for _, r := range expandDirections(top) { + chain, expr, err := f.MarshalRule(r) + if err != nil { + return err + } + if f.ruleExists(existing[chain], r) { + continue + } + fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr) + existing[chain] = append(existing[chain], r) + n++ + } + } + if n == 0 { + return nil + } + _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-") + return err +} + +// ReplaceRulesBatch atomically flushes the private table's input and output +// chains and re-adds exactly rules, all in one `nft -f` transaction. Chain hooks +// and policies are preserved (flush chain removes only rules). It implements +// RuleBatcher. +func (f *NFT) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + + var script strings.Builder + fmt.Fprintf(&script, "flush chain inet %s input\n", f.table) + fmt.Fprintf(&script, "flush chain inet %s output\n", f.table) + for _, top := range rules { + // A DirAny rule fans out into an input row plus its swapped output row. + for _, r := range expandDirections(top) { + chain, expr, err := f.MarshalRule(r) + if err != nil { + return err + } + fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr) + } + } + _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-") + return err +} diff --git a/pf.go b/pf.go index bff3258..7099a6e 100644 --- a/pf.go +++ b/pf.go @@ -12,72 +12,6 @@ import ( "strings" ) -// readFileLines reads a file and returns its lines with trailing newlines -// stripped. -func (f *PF) readFileLines(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) - // pf.conf can carry a very long single line (e.g. a large table macro); raise - // the token cap well above the default 64 KB so such a line is not rejected. - scanner.Buffer(make([]byte, 0, 64*1024), 64*1024*1024) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - return lines, scanner.Err() -} - -// writeFileLines atomically replaces path with the provided lines by writing to -// a uniquely-named temp file in the same directory and renaming it into place. -// The original file's mode and ownership are preserved (defaulting to 0600 for a -// new file) so a rewrite never loosens restrictive permissions. -func (f *PF) writeFileLines(path string, lines []string) error { - af, err := newAtomicFile(path, 0600) - if err != nil { - return err - } - defer af.Abort() - w := bufio.NewWriter(af) - for _, line := range lines { - _, _ = fmt.Fprintln(w, line) - } - if err := w.Flush(); err != nil { - return err - } - return af.Commit() -} - -const ( - // PFType is the backend type string reported by PF.Type. - PFType = "pf" - // PFDefaultAnchor is the pf anchor name used when no rule prefix is supplied. - PFDefaultAnchor = "go_firewall" - // PFConf is the main pf configuration file. - PFConf = "/etc/pf.conf" -) - -// PF manages firewall rules through OpenBSD's Packet Filter (pf), used on both -// macOS and FreeBSD. To avoid disturbing rules owned by the base system, this -// backend keeps every rule it creates inside a private anchor (named after the -// rule prefix). The anchor is referenced from the main pf.conf so its rules are -// evaluated, but the rules themselves are loaded and read through pfctl scoped -// to the anchor. -type PF struct { - // anchor is the pf anchor this backend owns. - anchor string - // ensured records whether the anchor reference has been added to pf.conf - // this session so we only check/patch it once. - ensured bool - // natEnsured records the same for the nat/rdr anchor references, added - // lazily only when a NAT rule is first written. - natEnsured bool -} - // sanitizePFName reduces an arbitrary prefix to a safe pf anchor name, falling // back to the default when nothing usable remains. func sanitizePFName(prefix string) string { @@ -127,192 +61,58 @@ func (f *PF) Type() string { return PFType } +// Capabilities returns the set of features the pf backend can express. +func (f *PF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + ICMPv6: true, + // pfctl expands a port list (`port { 80 443 }`) into one rule per port when + // it lists the ruleset, so a discrete multi-port rule does not round-trip as + // a single rule (a range, kept as one token, does). Report PortList as + // unsupported to reflect that; callers open several ports with a rule each. + PortList: false, + ConnState: false, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: true, + DefaultPolicy: false, + RuleCounters: true, + AddressSets: true, + Comments: true, + } +} + // GetZone reports no zone; pf has no interface-to-zone mapping in the model we expose. func (f *PF) GetZone(ctx context.Context, iface string) (zoneName string, err error) { return "", nil } -// ensureAnchor makes sure pf.conf references our anchor so that rules loaded -// into it are evaluated. If the reference is missing it is appended and pf.conf -// is reloaded. Filter anchors are evaluated in place, so appending keeps our -// rules after the base ruleset. -func (f *PF) ensureAnchor(ctx context.Context) error { - if f.ensured { - return nil +// parseAddr reads an address operand starting at index i, honoring an optional +// leading '!' negation (pf allows `! host` or `!host`). It returns the address, +// the negation prefix to prepend ("" or "!"), and the index of the last token +// consumed. +func (f *PF) parseAddr(tokens []string, i int) (val string, neg string, next int, err error) { + if i >= len(tokens) { + return "", "", 0, fmt.Errorf("missing address value") } - - data, err := f.readFileLines(PFConf) - if err != nil { - return err - } - - anchorRef := fmt.Sprintf(`anchor "%s"`, f.anchor) - for _, line := range data { - if strings.TrimSpace(line) == anchorRef { - f.ensured = true - return nil + tok := tokens[i] + if tok == "!" { + neg = "!" + i++ + if i >= len(tokens) { + return "", "", 0, fmt.Errorf("missing address value") } + tok = tokens[i] + } else if strings.HasPrefix(tok, "!") { + neg = "!" + // Strip the negation from a local copy; the caller's slice must not be + // mutated in place. + tok = strings.TrimPrefix(tok, "!") } - - // Append the anchor reference and reload the main ruleset. - data = append(data, anchorRef) - if err := f.writeFileLines(PFConf, data); err != nil { - return err - } - if _, err := runCommand(ctx, "pfctl", "-f", PFConf); err != nil { - return fmt.Errorf("failed to reload pf.conf after adding anchor: %s", err) - } - f.ensured = true - return nil -} - -// pfFilterKeywords are the tokens that begin a pf filtering-section statement. -// Translation anchors (nat/rdr) must be declared before the first of these, so -// ensureNATAnchors inserts them at that boundary. pf.conf sections are strictly -// ordered options → normalization → queueing → translation → filtering, so the -// queueing keywords (altq/queue) are deliberately NOT included: they precede the -// translation section, and treating one as the boundary would splice the nat/rdr -// anchors ahead of the queueing statements, which pfctl -f rejects. -var pfFilterKeywords = map[string]bool{ - "pass": true, "block": true, "match": true, "anchor": true, - "antispoof": true, -} - -// translationBoundary returns the index of the first filtering statement in a -// pf.conf, which is where the nat/rdr translation anchors must be inserted (see -// pfFilterKeywords for why queueing keywords are excluded). When there is no -// filtering statement the boundary is the end of the file, so anchors are appended. -func (f *PF) translationBoundary(data []string) int { - for i, line := range data { - fields := strings.Fields(strings.TrimSpace(line)) - if len(fields) > 0 && pfFilterKeywords[fields[0]] { - return i - } - } - return len(data) -} - -// ensureNATAnchors makes sure pf.conf references our nat and rdr anchors so that -// translation rules loaded into the anchor are evaluated. pf requires -// translation rules (and their anchors) to appear before any filtering -// statement, so the references are inserted at that boundary rather than -// appended. Missing references are added and the main ruleset reloaded. -func (f *PF) ensureNATAnchors(ctx context.Context) error { - if f.natEnsured { - return nil - } - if err := f.ensureAnchor(ctx); err != nil { - return err - } - - data, err := f.readFileLines(PFConf) - if err != nil { - return err - } - - natRef := fmt.Sprintf(`nat-anchor "%s"`, f.anchor) - rdrRef := fmt.Sprintf(`rdr-anchor "%s"`, f.anchor) - haveNat, haveRdr := false, false - for _, line := range data { - trimmed := strings.TrimSpace(line) - if trimmed == natRef { - haveNat = true - } - if trimmed == rdrRef { - haveRdr = true - } - } - insertAt := f.translationBoundary(data) - if haveNat && haveRdr { - f.natEnsured = true - return nil - } - - var add []string - if !haveRdr { - add = append(add, rdrRef) - } - if !haveNat { - add = append(add, natRef) - } - updated := make([]string, 0, len(data)+len(add)) - updated = append(updated, data[:insertAt]...) - updated = append(updated, add...) - updated = append(updated, data[insertAt:]...) - - if err := f.writeFileLines(PFConf, updated); err != nil { - return err - } - if _, err := runCommand(ctx, "pfctl", "-f", PFConf); err != nil { - return fmt.Errorf("failed to reload pf.conf after adding nat anchors: %s", err) - } - f.natEnsured = true - return nil -} - -// rateUnitSeconds converts a RateUnit to the number of seconds pf expresses a -// rate over (pf writes max-src-conn-rate as /). -func (f *PF) rateUnitSeconds(u RateUnit) int { - switch u { - case PerMinute: - return 60 - case PerHour: - return 3600 - case PerDay: - return 86400 - } - return 1 -} - -// rateUnitFromSeconds maps a pf rate window in seconds back to a RateUnit, -// falling back to PerSecond for a window that matches no named unit. -func (f *PF) rateUnitFromSeconds(s int) RateUnit { - switch s { - case 60: - return PerMinute - case 3600: - return PerHour - case 86400: - return PerDay - } - return PerSecond -} - -// protoName returns the protocol keyword pf uses; pf spells ICMPv6 as -// `icmp6`. -func (f *PF) protoName(p Protocol) string { - if p == ICMPv6 { - return "icmp6" - } - return p.String() -} - -// pfICMPTypeNames maps the icmp-type names pfctl prints (which differ from the -// hyphenated aliases in icmpNameToNum, e.g. `echoreq` vs `echo-request`) to their -// numeric type, so an icmp-type match round-trips whether pfctl emits a number or -// a name. -var pfICMPTypeNames = map[string]uint8{ - "echorep": 0, "unreach": 3, "squench": 4, "redir": 5, "althost": 6, - "echoreq": 8, "routeradv": 9, "routersol": 10, "timex": 11, - "paramprob": 12, "timereq": 13, "timerep": 14, "inforeq": 15, - "inforep": 16, "maskreq": 17, "maskrep": 18, "trace": 30, - "dataconv": 31, "mobredir": 32, "ipv6-where": 33, "ipv6-here": 34, - "mobregreq": 35, "mobregrep": 36, "skip": 39, "photuris": 40, -} - -// pfICMP6TypeNames maps the icmp6-type names pfctl prints to their numeric -// ICMPv6 type. Several spellings collide with the ICMPv4 names in -// pfICMPTypeNames but mean a different number (e.g. `unreach` is 3 for ICMPv4 -// but 1 for ICMPv6, `echoreq` is 8 vs 128), so an icmp6-type match must be -// resolved through this table rather than the ICMPv4 one. -var pfICMP6TypeNames = map[string]uint8{ - "unreach": 1, "toobig": 2, "timex": 3, "paramprob": 4, - "echoreq": 128, "echorep": 129, - "groupqry": 130, "listqry": 130, "grouprep": 131, "listenrep": 131, - "groupterm": 132, "listendone": 132, - "routersol": 133, "routeradv": 134, "neighbrsol": 135, "neighbradv": 136, - "redir": 137, "routrrenum": 138, - "fqdnreq": 139, "niqry": 139, "fqdnrep": 140, "nirep": 140, + return tok, neg, i, nil } // parseICMPType resolves a pf icmp-type token: a number, or a name known to @@ -331,29 +131,6 @@ func (f *PF) parseICMPType(tok string, v6 bool) (uint8, bool) { return n, ok } -// portMember renders one port spec in pf syntax: "80" for a single port or -// "1000:2000" for a range. -func (f *PF) portMember(pr PortRange) string { - pr = pr.normalized() - if pr.Start == pr.End { - return strconv.FormatUint(uint64(pr.Start), 10) - } - return fmt.Sprintf("%d:%d", pr.Start, pr.End) -} - -// portExpr renders a destination port match: a bare value for a single spec or -// a pf list `{ 80 443 1000:2000 }` for several. -func (f *PF) portExpr(specs []PortRange) string { - if len(specs) == 1 { - return f.portMember(specs[0]) - } - members := make([]string, len(specs)) - for i, pr := range specs { - members[i] = f.portMember(pr) - } - return "{ " + strings.Join(members, " ") + " }" -} - // lookupPort resolves a pf port token to its number. pfctl prints a well-known // port by its /etc/services name (22 -> ssh, 80 -> http, ...), so a non-numeric // token is looked up as a service name. pf does not record the protocol alongside @@ -421,188 +198,75 @@ func (f *PF) parsePorts(tokens []string, i int) (specs []PortRange, next int, er return []PortRange{pr}, i, nil } -// MarshalRule encodes a rule as a pf rule line suitable for loading into our -// anchor. Rules are marked `quick` so the first match wins, matching the -// allow/deny-list semantics of the other backends. -func (f *PF) MarshalRule(r *Rule) (string, error) { - // pf filters by the interface a packet passes on (`pass in`/`pass out`) and has - // no distinct forward chain, so a forward rule cannot be expressed in this model. - if r.IsForward() { - return "", unsupportedForward("pf") - } - // pf can only match a port alongside a concrete transport protocol. - if r.PortNeedsConcreteProtocol() { - return "", fmt.Errorf("a port requires a tcp, udp or sctp protocol") - } - if err := r.checkICMPType(); err != nil { - return "", err +// rateUnitFromSeconds maps a pf rate window in seconds back to a RateUnit, +// falling back to PerSecond for a window that matches no named unit. +func (f *PF) rateUnitFromSeconds(s int) RateUnit { + switch s { + case 60: + return PerMinute + case 3600: + return PerHour + case 86400: + return PerDay } + return PerSecond +} - // pfctl expands a discrete source-port list into one rule per port on read, so it - // would not round-trip as a single rule; reject it (a contiguous range is one - // token and does round-trip). - if len(r.SourcePortSpecs()) > 1 { - return "", fmt.Errorf("pf cannot express a source-port list as a single rule: %w", ErrUnsupportedSourcePort) - } - // pfctl expands a discrete destination-port list the same way (the PortList - // capability is false), so reject a genuine list of more than one spec. - if len(r.PortSpecs()) > 1 { - return "", fmt.Errorf("pf cannot express a destination-port list as a single rule: %w", ErrUnsupported) - } - - // pf keeps state on pass rules automatically; it has no equivalent of the - // conntrack-state match the model exposes, so reject rather than drop it. - if r.State != 0 { - return "", fmt.Errorf("pf does not support connection-state matching: %w", ErrUnsupportedState) - } - - // pf's `log` keyword carries no text prefix, so a LogPrefix cannot be - // represented; reject rather than silently drop the label. - if r.LogPrefix != "" { - return "", fmt.Errorf("pf does not support a log prefix: %w", ErrUnsupportedLog) - } - - // A pf rule binds a single interface via `on`, tied to the rule direction. - if r.IsOutput() && r.InInterface != "" { - return "", fmt.Errorf("an input interface cannot be matched on an output rule") - } - if !r.IsOutput() && r.OutInterface != "" { - return "", fmt.Errorf("an output interface cannot be matched on an input rule") - } - - var parts []string - - // Action. - switch r.Action { - case Accept: - parts = append(parts, "pass") - case Drop: - parts = append(parts, "block", "drop") - case Reject: - parts = append(parts, "block", "return") - default: - return "", fmt.Errorf("no valid action was provided") - } - - // Direction. - if r.IsOutput() { - parts = append(parts, "out") - } else { - parts = append(parts, "in") - } - - // Logging, emitted right after the direction as pfctl normalizes it. Packet - // capture requires a pflog interface; the rule syntax is valid regardless. - if r.Log { - parts = append(parts, "log") - } - - // First match wins. - parts = append(parts, "quick") - - // Interface, bound to the rule's direction. - iface := r.InInterface - if r.IsOutput() { - iface = r.OutInterface - } - if iface != "" { - parts = append(parts, "on", iface) - } - - // Address family. pf requires an explicit family for an icmp-type/icmp6-type - // match, and an ICMP protocol implies one (ICMP => inet, ICMPv6 => inet6), so - // resolve it rather than emitting the family only when set explicitly. - switch r.impliedFamily() { - case IPv4: - parts = append(parts, "inet") - case IPv6: - parts = append(parts, "inet6") - } - - // Protocol. - if r.Proto != ProtocolAny { - parts = append(parts, "proto", f.protoName(r.Proto)) - } - - // Source and optional source port(s). - srcSpecs := r.SourcePortSpecs() - if r.Source != "" { - // A non-address token names a pf table, referenced as ``. - neg, bare := splitAddrNeg(r.Source) - if neg { - parts = append(parts, "from", "!", f.addrToken(bare)) - } else { - parts = append(parts, "from", f.addrToken(bare)) +// parseStateOpts parses a pf state-option group `( ... )` starting at +// tokens[i] (strings.Fields has split it on spaces, so the members are +// reassembled) and records any rate/connection limits on r. It returns the +// index of the token that closed the group. +func (f *PF) parseStateOpts(tokens []string, i int, r *Rule) (int, error) { + var b strings.Builder + for ; i < len(tokens); i++ { + if b.Len() > 0 { + b.WriteByte(' ') } - if len(srcSpecs) > 0 { - parts = append(parts, "port", f.portExpr(srcSpecs)) + b.WriteString(tokens[i]) + if strings.HasSuffix(tokens[i], ")") { + break } - } else if len(srcSpecs) > 0 { - parts = append(parts, "from", "any", "port", f.portExpr(srcSpecs)) - } else { - parts = append(parts, "from", "any") } - - // Destination and optional destination port(s). - dstSpecs := r.PortSpecs() - if r.Destination != "" { - neg, bare := splitAddrNeg(r.Destination) - if neg { - parts = append(parts, "to", "!", f.addrToken(bare)) - } else { - parts = append(parts, "to", f.addrToken(bare)) + group := strings.TrimSpace(b.String()) + group = strings.TrimSuffix(strings.TrimPrefix(group, "("), ")") + for _, opt := range strings.Split(group, ",") { + fields := strings.Fields(strings.TrimSpace(opt)) + if len(fields) < 2 { + continue } - if len(dstSpecs) > 0 { - parts = append(parts, "port", f.portExpr(dstSpecs)) - } - } else if len(dstSpecs) > 0 { - parts = append(parts, "to", "any", "port", f.portExpr(dstSpecs)) - } else { - parts = append(parts, "to", "any") - } - - // An ICMP type match. pf places it after the from/to addresses (and requires - // the address family emitted above); it spells the ICMPv6 keyword icmp6-type. - if r.Proto.IsICMP() && r.ICMPType != nil { - kw := "icmp-type" - if r.Proto == ICMPv6 { - kw = "icmp6-type" - } - parts = append(parts, kw, strconv.FormatUint(uint64(*r.ICMPType), 10)) - } - - // Rate / connection limits. pf expresses these as per-source state-tracking - // options, valid only on stateful pass rules. - if r.RateLimit != nil || r.ConnLimit != nil { - if r.Action != Accept { - return "", fmt.Errorf("pf rate/connection limiting is only supported on accept rules: %w", ErrUnsupported) - } - var opts []string - if r.ConnLimit != nil { - if !r.ConnLimit.PerSource { - return "", fmt.Errorf("pf connection limiting is per-source only: %w", ErrUnsupportedConnLimit) + switch fields[0] { + case "max-src-conn": + n, err := strconv.ParseUint(fields[1], 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid max-src-conn %q", fields[1]) } - opts = append(opts, fmt.Sprintf("max-src-conn %d", r.ConnLimit.Count)) - } - if r.RateLimit != nil { - // pf's max-src-conn-rate has no burst term, so a requested burst cannot - // be honored. Reject it rather than emit a rule that reads back with - // Burst 0 and fails rule-identity comparison. - if r.RateLimit.Burst != 0 { - return "", fmt.Errorf("pf does not support a rate-limit burst: %w", ErrUnsupported) + r.ConnLimit = &ConnLimit{Count: uint(n), PerSource: true} + case "max-src-conn-rate": + cnt, secs, ok := strings.Cut(fields[1], "/") + if !ok { + return 0, fmt.Errorf("invalid max-src-conn-rate %q", fields[1]) } - opts = append(opts, fmt.Sprintf("max-src-conn-rate %d/%d", r.RateLimit.Rate, f.rateUnitSeconds(r.RateLimit.Unit))) + n, err := strconv.ParseUint(cnt, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid rate %q", fields[1]) + } + s, err := strconv.Atoi(secs) + if err != nil { + return 0, fmt.Errorf("invalid rate window %q", fields[1]) + } + r.RateLimit = &RateLimit{Rate: uint(n), Unit: f.rateUnitFromSeconds(s)} } - parts = append(parts, "keep", "state", "("+strings.Join(opts, ", ")+")") } + return i, nil +} - // An optional user comment, carried as a pf rule label. It has no effect on - // matching and is ignored when comparing rules. - if r.Comment != "" { - parts = append(parts, "label", strconv.Quote(r.Comment)) +// stripTable removes the angle brackets pf prints around a table reference, +// yielding the bare set name stored in Source/Destination. +func (f *PF) stripTable(v string) string { + if strings.HasPrefix(v, "<") && strings.HasSuffix(v, ">") { + return v[1 : len(v)-1] } - - return strings.Join(parts, " "), nil + return v } // UnmarshalRule decodes a single pf rule line as produced by `pfctl -sr`. pfctl @@ -845,112 +509,27 @@ func (f *PF) UnmarshalRule(line string) (*Rule, error) { return r, nil } -// addrToken renders a source/destination value for a pf rule: a bare address, or -// a table reference `` when the token names an address set. The caller emits -// any leading "!" negation separately. -func (f *PF) addrToken(bare string) string { - if _, ok := canonAddr(bare); !ok { - return "<" + bare + ">" - } - return bare -} - -// stripTable removes the angle brackets pf prints around a table reference, -// yielding the bare set name stored in Source/Destination. -func (f *PF) stripTable(v string) string { - if strings.HasPrefix(v, "<") && strings.HasSuffix(v, ">") { - return v[1 : len(v)-1] - } - return v -} - -// parseAddr reads an address operand starting at index i, honoring an optional -// leading '!' negation (pf allows `! host` or `!host`). It returns the address, -// the negation prefix to prepend ("" or "!"), and the index of the last token -// consumed. -func (f *PF) parseAddr(tokens []string, i int) (val string, neg string, next int, err error) { - if i >= len(tokens) { - return "", "", 0, fmt.Errorf("missing address value") - } - tok := tokens[i] - if tok == "!" { - neg = "!" - i++ - if i >= len(tokens) { - return "", "", 0, fmt.Errorf("missing address value") - } - tok = tokens[i] - } else if strings.HasPrefix(tok, "!") { - neg = "!" - // Strip the negation from a local copy; the caller's slice must not be - // mutated in place. - tok = strings.TrimPrefix(tok, "!") - } - return tok, neg, i, nil -} - -// parseStateOpts parses a pf state-option group `( ... )` starting at -// tokens[i] (strings.Fields has split it on spaces, so the members are -// reassembled) and records any rate/connection limits on r. It returns the -// index of the token that closed the group. -func (f *PF) parseStateOpts(tokens []string, i int, r *Rule) (int, error) { - var b strings.Builder - for ; i < len(tokens); i++ { - if b.Len() > 0 { - b.WriteByte(' ') - } - b.WriteString(tokens[i]) - if strings.HasSuffix(tokens[i], ")") { - break - } - } - group := strings.TrimSpace(b.String()) - group = strings.TrimSuffix(strings.TrimPrefix(group, "("), ")") - for _, opt := range strings.Split(group, ",") { - fields := strings.Fields(strings.TrimSpace(opt)) - if len(fields) < 2 { - continue - } - switch fields[0] { - case "max-src-conn": - n, err := strconv.ParseUint(fields[1], 10, 32) - if err != nil { - return 0, fmt.Errorf("invalid max-src-conn %q", fields[1]) +// parseRuleCounters extracts the Packets and Bytes counts from a pfctl -vsr +// statistics continuation line, e.g. +// "[ Evaluations: 5 Packets: 10 Bytes: 600 States: 0 ]". It returns ok=false +// for a continuation line that carries no counters, so a non-statistics line is +// ignored rather than mistaken for a zeroed counter. +func (f *PF) parseRuleCounters(line string) (packets, bytes uint64, ok bool) { + fields := strings.Fields(strings.Trim(line, "[] ")) + var haveP, haveB bool + for i := 0; i+1 < len(fields); i++ { + switch fields[i] { + case "Packets:": + if v, err := strconv.ParseUint(fields[i+1], 10, 64); err == nil { + packets, haveP = v, true } - r.ConnLimit = &ConnLimit{Count: uint(n), PerSource: true} - case "max-src-conn-rate": - cnt, secs, ok := strings.Cut(fields[1], "/") - if !ok { - return 0, fmt.Errorf("invalid max-src-conn-rate %q", fields[1]) + case "Bytes:": + if v, err := strconv.ParseUint(fields[i+1], 10, 64); err == nil { + bytes, haveB = v, true } - n, err := strconv.ParseUint(cnt, 10, 32) - if err != nil { - return 0, fmt.Errorf("invalid rate %q", fields[1]) - } - s, err := strconv.Atoi(secs) - if err != nil { - return 0, fmt.Errorf("invalid rate window %q", fields[1]) - } - r.RateLimit = &RateLimit{Rate: uint(n), Unit: f.rateUnitFromSeconds(s)} } } - return i, nil -} - -// anchorRules returns the filter rules currently loaded in our anchor. -func (f *PF) anchorRules(ctx context.Context) (rules []*Rule, raw []string, err error) { - // Read with -vsr so pfctl prints each rule's per-rule counters on a following - // `[ Evaluations: N Packets: N Bytes: N States: N ]` continuation line, - // which parseAnchorRules attaches to the preceding rule (RuleCounters). - out, err := runCommand(ctx, "pfctl", "-a", f.anchor, "-vsr") - if err != nil { - // Propagate a genuine pfctl failure rather than reporting an empty anchor: a - // referenced anchor lists nothing with a zero exit when empty, so an error - // here is a real read failure. - return nil, nil, err - } - rules, raw = f.parseAnchorRules(out) - return rules, raw, nil + return packets, bytes, haveP && haveB } // parseAnchorRules decodes the lines of a `pfctl -a -vsr` listing into @@ -1009,6 +588,22 @@ func (f *PF) parseAnchorRules(out []string) (rules []*Rule, raw []string) { return rules, raw } +// anchorRules returns the filter rules currently loaded in our anchor. +func (f *PF) anchorRules(ctx context.Context) (rules []*Rule, raw []string, err error) { + // Read with -vsr so pfctl prints each rule's per-rule counters on a following + // `[ Evaluations: N Packets: N Bytes: N States: N ]` continuation line, + // which parseAnchorRules attaches to the preceding rule (RuleCounters). + out, err := runCommand(ctx, "pfctl", "-a", f.anchor, "-vsr") + if err != nil { + // Propagate a genuine pfctl failure rather than reporting an empty anchor: a + // referenced anchor lists nothing with a zero exit when empty, so an error + // here is a real read failure. + return nil, nil, err + } + rules, raw = f.parseAnchorRules(out) + return rules, raw, nil +} + // compactRules drops the opaque (nil) placeholder rows parseAnchorRules keeps // for lines it cannot model, leaving only the rules the library represents. The // read/merge/number and backup paths use it, since those operate on the modeled @@ -1023,50 +618,6 @@ func (f *PF) compactRules(rules []*Rule) []*Rule { return out } -// filterAnchors maps each logical (merged) filter rule to its physical row index -// in the anchor, skipping opaque (nil) rows so an unmodeled foreign line occupying -// a physical slot does not consume a logical position. With no opaque rows it -// equals mergedFamilyAnchors. It backs the merged-position insert/move mapping. -func (f *PF) filterAnchors(rules []*Rule) []int { - phys := make([]int, 0, len(rules)) - modeled := make([]*Rule, 0, len(rules)) - for i, r := range rules { - if r == nil { - continue - } - phys = append(phys, i) - modeled = append(modeled, r) - } - anchors := mergedFamilyAnchors(modeled) - for k := range anchors { - anchors[k] = phys[anchors[k]] - } - return anchors -} - -// parseRuleCounters extracts the Packets and Bytes counts from a pfctl -vsr -// statistics continuation line, e.g. -// "[ Evaluations: 5 Packets: 10 Bytes: 600 States: 0 ]". It returns ok=false -// for a continuation line that carries no counters, so a non-statistics line is -// ignored rather than mistaken for a zeroed counter. -func (f *PF) parseRuleCounters(line string) (packets, bytes uint64, ok bool) { - fields := strings.Fields(strings.Trim(line, "[] ")) - var haveP, haveB bool - for i := 0; i+1 < len(fields); i++ { - switch fields[i] { - case "Packets:": - if v, err := strconv.ParseUint(fields[i+1], 10, 64); err == nil { - packets, haveP = v, true - } - case "Bytes:": - if v, err := strconv.ParseUint(fields[i+1], 10, 64); err == nil { - bytes, haveB = v, true - } - } - } - return packets, bytes, haveP && haveB -} - // listForeignRules returns best-effort filter rules loaded outside this backend's // own anchor — the main ruleset and any other anchors. pf has no JSON mode and // foreign rules may use constructs the library's Rule model cannot represent, so @@ -1110,6 +661,421 @@ func (f *PF) listForeignRules(ctx context.Context) []*Rule { return rules } +// GetRules returns the existing filter rules from the zone. +func (f *PF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + rules, _, err = f.anchorRules(ctx) + if err != nil { + return nil, err + } + // Drop the opaque placeholder rows kept for unmodeled anchor lines; GetRules + // reports only the rules the library can represent. + rules = f.compactRules(rules) + // Merge the library's own IPv4/IPv6 pairs, then number the anchor's rules as one + // ordered list (pf evaluates a single filter list, so its position spans + // directions). Numbering after the merge keeps a collapsed pair from leaving a + // gap. Foreign rules appended below live outside this anchor and keep Number 0. + rules = mergeFamilies(rules) + numberSequential(rules) + // Collapse each input/output twin into one DirAny rule. Numbering first keeps + // the surviving rows' sequential position intact. + rules = mergeDirections(rules) + rules = append(rules, f.listForeignRules(ctx)...) + return rules, nil +} + +// addrToken renders a source/destination value for a pf rule: a bare address, or +// a table reference `` when the token names an address set. The caller emits +// any leading "!" negation separately. +func (f *PF) addrToken(bare string) string { + if _, ok := canonAddr(bare); !ok { + return "<" + bare + ">" + } + return bare +} + +// portMember renders one port spec in pf syntax: "80" for a single port or +// "1000:2000" for a range. +func (f *PF) portMember(pr PortRange) string { + pr = pr.normalized() + if pr.Start == pr.End { + return strconv.FormatUint(uint64(pr.Start), 10) + } + return fmt.Sprintf("%d:%d", pr.Start, pr.End) +} + +// portExpr renders a destination port match: a bare value for a single spec or +// a pf list `{ 80 443 1000:2000 }` for several. +func (f *PF) portExpr(specs []PortRange) string { + if len(specs) == 1 { + return f.portMember(specs[0]) + } + members := make([]string, len(specs)) + for i, pr := range specs { + members[i] = f.portMember(pr) + } + return "{ " + strings.Join(members, " ") + " }" +} + +// protoName returns the protocol keyword pf uses; pf spells ICMPv6 as +// `icmp6`. +func (f *PF) protoName(p Protocol) string { + if p == ICMPv6 { + return "icmp6" + } + return p.String() +} + +// pfICMPTypeNames maps the icmp-type names pfctl prints (which differ from the +// hyphenated aliases in icmpNameToNum, e.g. `echoreq` vs `echo-request`) to their +// numeric type, so an icmp-type match round-trips whether pfctl emits a number or +// a name. +var pfICMPTypeNames = map[string]uint8{ + "echorep": 0, "unreach": 3, "squench": 4, "redir": 5, "althost": 6, + "echoreq": 8, "routeradv": 9, "routersol": 10, "timex": 11, + "paramprob": 12, "timereq": 13, "timerep": 14, "inforeq": 15, + "inforep": 16, "maskreq": 17, "maskrep": 18, "trace": 30, + "dataconv": 31, "mobredir": 32, "ipv6-where": 33, "ipv6-here": 34, + "mobregreq": 35, "mobregrep": 36, "skip": 39, "photuris": 40, +} + +// pfICMP6TypeNames maps the icmp6-type names pfctl prints to their numeric +// ICMPv6 type. Several spellings collide with the ICMPv4 names in +// pfICMPTypeNames but mean a different number (e.g. `unreach` is 3 for ICMPv4 +// but 1 for ICMPv6, `echoreq` is 8 vs 128), so an icmp6-type match must be +// resolved through this table rather than the ICMPv4 one. +var pfICMP6TypeNames = map[string]uint8{ + "unreach": 1, "toobig": 2, "timex": 3, "paramprob": 4, + "echoreq": 128, "echorep": 129, + "groupqry": 130, "listqry": 130, "grouprep": 131, "listenrep": 131, + "groupterm": 132, "listendone": 132, + "routersol": 133, "routeradv": 134, "neighbrsol": 135, "neighbradv": 136, + "redir": 137, "routrrenum": 138, + "fqdnreq": 139, "niqry": 139, "fqdnrep": 140, "nirep": 140, +} + +// rateUnitSeconds converts a RateUnit to the number of seconds pf expresses a +// rate over (pf writes max-src-conn-rate as /). +func (f *PF) rateUnitSeconds(u RateUnit) int { + switch u { + case PerMinute: + return 60 + case PerHour: + return 3600 + case PerDay: + return 86400 + } + return 1 +} + +// MarshalRule encodes a rule as a pf rule line suitable for loading into our +// anchor. Rules are marked `quick` so the first match wins, matching the +// allow/deny-list semantics of the other backends. +func (f *PF) MarshalRule(r *Rule) (string, error) { + // pf filters by the interface a packet passes on (`pass in`/`pass out`) and has + // no distinct forward chain, so a forward rule cannot be expressed in this model. + if r.IsForward() { + return "", unsupportedForward("pf") + } + // pf can only match a port alongside a concrete transport protocol. + if r.PortNeedsConcreteProtocol() { + return "", fmt.Errorf("a port requires a tcp, udp or sctp protocol") + } + if err := r.checkICMPType(); err != nil { + return "", err + } + + // pfctl expands a discrete source-port list into one rule per port on read, so it + // would not round-trip as a single rule; reject it (a contiguous range is one + // token and does round-trip). + if len(r.SourcePortSpecs()) > 1 { + return "", fmt.Errorf("pf cannot express a source-port list as a single rule: %w", ErrUnsupportedSourcePort) + } + // pfctl expands a discrete destination-port list the same way (the PortList + // capability is false), so reject a genuine list of more than one spec. + if len(r.PortSpecs()) > 1 { + return "", fmt.Errorf("pf cannot express a destination-port list as a single rule: %w", ErrUnsupported) + } + + // pf keeps state on pass rules automatically; it has no equivalent of the + // conntrack-state match the model exposes, so reject rather than drop it. + if r.State != 0 { + return "", fmt.Errorf("pf does not support connection-state matching: %w", ErrUnsupportedState) + } + + // pf's `log` keyword carries no text prefix, so a LogPrefix cannot be + // represented; reject rather than silently drop the label. + if r.LogPrefix != "" { + return "", fmt.Errorf("pf does not support a log prefix: %w", ErrUnsupportedLog) + } + + // A pf rule binds a single interface via `on`, tied to the rule direction. + if r.IsOutput() && r.InInterface != "" { + return "", fmt.Errorf("an input interface cannot be matched on an output rule") + } + if !r.IsOutput() && r.OutInterface != "" { + return "", fmt.Errorf("an output interface cannot be matched on an input rule") + } + + var parts []string + + // Action. + switch r.Action { + case Accept: + parts = append(parts, "pass") + case Drop: + parts = append(parts, "block", "drop") + case Reject: + parts = append(parts, "block", "return") + default: + return "", fmt.Errorf("no valid action was provided") + } + + // Direction. + if r.IsOutput() { + parts = append(parts, "out") + } else { + parts = append(parts, "in") + } + + // Logging, emitted right after the direction as pfctl normalizes it. Packet + // capture requires a pflog interface; the rule syntax is valid regardless. + if r.Log { + parts = append(parts, "log") + } + + // First match wins. + parts = append(parts, "quick") + + // Interface, bound to the rule's direction. + iface := r.InInterface + if r.IsOutput() { + iface = r.OutInterface + } + if iface != "" { + parts = append(parts, "on", iface) + } + + // Address family. pf requires an explicit family for an icmp-type/icmp6-type + // match, and an ICMP protocol implies one (ICMP => inet, ICMPv6 => inet6), so + // resolve it rather than emitting the family only when set explicitly. + switch r.impliedFamily() { + case IPv4: + parts = append(parts, "inet") + case IPv6: + parts = append(parts, "inet6") + } + + // Protocol. + if r.Proto != ProtocolAny { + parts = append(parts, "proto", f.protoName(r.Proto)) + } + + // Source and optional source port(s). + srcSpecs := r.SourcePortSpecs() + if r.Source != "" { + // A non-address token names a pf table, referenced as ``. + neg, bare := splitAddrNeg(r.Source) + if neg { + parts = append(parts, "from", "!", f.addrToken(bare)) + } else { + parts = append(parts, "from", f.addrToken(bare)) + } + if len(srcSpecs) > 0 { + parts = append(parts, "port", f.portExpr(srcSpecs)) + } + } else if len(srcSpecs) > 0 { + parts = append(parts, "from", "any", "port", f.portExpr(srcSpecs)) + } else { + parts = append(parts, "from", "any") + } + + // Destination and optional destination port(s). + dstSpecs := r.PortSpecs() + if r.Destination != "" { + neg, bare := splitAddrNeg(r.Destination) + if neg { + parts = append(parts, "to", "!", f.addrToken(bare)) + } else { + parts = append(parts, "to", f.addrToken(bare)) + } + if len(dstSpecs) > 0 { + parts = append(parts, "port", f.portExpr(dstSpecs)) + } + } else if len(dstSpecs) > 0 { + parts = append(parts, "to", "any", "port", f.portExpr(dstSpecs)) + } else { + parts = append(parts, "to", "any") + } + + // An ICMP type match. pf places it after the from/to addresses (and requires + // the address family emitted above); it spells the ICMPv6 keyword icmp6-type. + if r.Proto.IsICMP() && r.ICMPType != nil { + kw := "icmp-type" + if r.Proto == ICMPv6 { + kw = "icmp6-type" + } + parts = append(parts, kw, strconv.FormatUint(uint64(*r.ICMPType), 10)) + } + + // Rate / connection limits. pf expresses these as per-source state-tracking + // options, valid only on stateful pass rules. + if r.RateLimit != nil || r.ConnLimit != nil { + if r.Action != Accept { + return "", fmt.Errorf("pf rate/connection limiting is only supported on accept rules: %w", ErrUnsupported) + } + var opts []string + if r.ConnLimit != nil { + if !r.ConnLimit.PerSource { + return "", fmt.Errorf("pf connection limiting is per-source only: %w", ErrUnsupportedConnLimit) + } + opts = append(opts, fmt.Sprintf("max-src-conn %d", r.ConnLimit.Count)) + } + if r.RateLimit != nil { + // pf's max-src-conn-rate has no burst term, so a requested burst cannot + // be honored. Reject it rather than emit a rule that reads back with + // Burst 0 and fails rule-identity comparison. + if r.RateLimit.Burst != 0 { + return "", fmt.Errorf("pf does not support a rate-limit burst: %w", ErrUnsupported) + } + opts = append(opts, fmt.Sprintf("max-src-conn-rate %d/%d", r.RateLimit.Rate, f.rateUnitSeconds(r.RateLimit.Unit))) + } + parts = append(parts, "keep", "state", "("+strings.Join(opts, ", ")+")") + } + + // An optional user comment, carried as a pf rule label. It has no effect on + // matching and is ignored when comparing rules. + if r.Comment != "" { + parts = append(parts, "label", strconv.Quote(r.Comment)) + } + + return strings.Join(parts, " "), nil +} + +// UnmarshalNATRule decodes a single pf nat/rdr rule line as produced by +// `pfctl -a -sn`. +func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) { + tokens := strings.Fields(line) + if len(tokens) == 0 { + return nil, fmt.Errorf("empty rule") + } + + r := new(NATRule) + i := 0 + switch tokens[i] { + case "rdr": + r.Kind = DNAT + case "nat": + r.Kind = SNAT // Refined to Masquerade below if the target is dynamic. + default: + return nil, fmt.Errorf("unsupported nat action: %s", tokens[i]) + } + i++ + + for ; i < len(tokens); i++ { + switch tokens[i] { + case "pass", "quick", "log": + // Qualifiers with no bearing on our model. + case "all": + // pfctl prints `all` for `from any to any`. + case "round-robin", "random", "source-hash", "bitmask", "static-port", "sticky-address": + // Address-pool / port options pfctl appends to a nat rule; ignored. + case "on": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing interface value") + } + r.Interface = tokens[i] + case "inet": + r.Family = IPv4 + case "inet6": + r.Family = IPv6 + case "proto": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing protocol value") + } + r.Proto = GetProtocol(tokens[i]) + if r.Proto == ProtocolAny { + return nil, fmt.Errorf("unsupported protocol: %s", tokens[i]) + } + case "from": + val, neg, next, err := f.parseAddr(tokens, i+1) + if err != nil { + return nil, err + } + i = next + if val != "any" { + r.Source = neg + f.stripTable(val) + } + case "to": + val, neg, next, err := f.parseAddr(tokens, i+1) + if err != nil { + return nil, err + } + i = next + if val != "any" { + r.Destination = neg + f.stripTable(val) + } + case "port": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing port value") + } + if tokens[i] == "=" { + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing port value") + } + } + specs, next, err := f.parsePorts(tokens, i) + if err != nil { + return nil, err + } + i = next + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.Port = specs[0].Start + } else { + r.Ports = specs + } + case "->": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing nat target") + } + target := tokens[i] + if strings.HasPrefix(target, "(") { + // A dynamic interface address is masquerade. + r.Kind = Masquerade + } else { + r.ToAddress = target + // An optional `port N` gives the translation port. pfctl prints a + // well-known target port by its /etc/services name (80 -> http), just + // like a match port, so resolve it through lookupPort rather than a + // number-only parse — otherwise a named target port fails to parse and + // the whole rule is dropped from the snapshot. + if i+2 < len(tokens) && tokens[i+1] == "port" { + p, err := f.lookupPort(tokens[i+2]) + if err != nil { + return nil, fmt.Errorf("invalid nat target port %q", tokens[i+2]) + } + r.ToPort = p + i += 2 + } + } + default: + return nil, fmt.Errorf("unsupported token: %s", tokens[i]) + } + } + + if r.Family == FamilyAny { + r.Family = r.impliedFamily() + } + if r.Kind == NATInvalid { + return nil, fmt.Errorf("no nat action was provided") + } + return r, nil +} + // anchorNATRules returns the nat/rdr rules currently loaded in our anchor. func (f *PF) anchorNATRules(ctx context.Context) (rules []*NATRule, raw []string, err error) { out, err := runCommand(ctx, "pfctl", "-a", f.anchor, "-sn") @@ -1141,95 +1107,116 @@ func (f *PF) anchorNATRules(ctx context.Context) (rules []*NATRule, raw []string return rules, raw, nil } -// compactNATRules is compactRules for NAT rules: it drops the opaque (nil) -// placeholder rows anchorNATRules keeps for unmodeled lines. -func (f *PF) compactNATRules(rules []*NATRule) []*NATRule { - out := make([]*NATRule, 0, len(rules)) - for _, r := range rules { - if r != nil { - out = append(out, r) - } - } - return out -} - -// natAnchors is filterAnchors for NAT rules: it maps each logical (merged) NAT -// rule to its physical row index, skipping opaque (nil) rows. -func (f *PF) natAnchors(rules []*NATRule) []int { - phys := make([]int, 0, len(rules)) - modeled := make([]*NATRule, 0, len(rules)) - for i, r := range rules { - if r == nil { - continue - } - phys = append(phys, i) - modeled = append(modeled, r) - } - anchors := mergedNATFamilyAnchors(modeled) - for k := range anchors { - anchors[k] = phys[anchors[k]] - } - return anchors -} - -// listForeignNATRules returns best-effort nat/rdr rules loaded outside this -// backend's own anchor — the main ruleset and any other anchors. Unparseable -// lines are skipped, as in listForeignRules. -func (f *PF) listForeignNATRules(ctx context.Context) []*NATRule { - var rules []*NATRule - // table records where each foreign NAT rule came from ("" for the main ruleset, - // the anchor name otherwise); not ours, so HasPrefix stays false. - parse := func(out []string, table string) { - for _, line := range out { - line = strings.TrimSpace(line) - if line == "" { - continue - } - rule, perr := f.UnmarshalNATRule(line) - if perr != nil || rule == nil { - continue - } - rule.table = table - rules = append(rules, rule) - } - } - if out, err := runCommand(ctx, "pfctl", "-sn"); err == nil { - parse(out, "") - } - if names, err := runCommand(ctx, "pfctl", "-s", "Anchors"); err == nil { - for _, name := range names { - name = strings.TrimSpace(name) - if name == "" || name == f.anchor { - continue - } - if out, err := runCommand(ctx, "pfctl", "-a", name, "-sn"); err == nil { - parse(out, name) - } - } - } - return rules -} - -// GetRules returns the existing filter rules from the zone. -func (f *PF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { - rules, _, err = f.anchorRules(ctx) +// readFileLines reads a file and returns its lines with trailing newlines +// stripped. +func (f *PF) readFileLines(path string) ([]string, error) { + fd, err := os.Open(path) if err != nil { return nil, err } - // Drop the opaque placeholder rows kept for unmodeled anchor lines; GetRules - // reports only the rules the library can represent. - rules = f.compactRules(rules) - // Merge the library's own IPv4/IPv6 pairs, then number the anchor's rules as one - // ordered list (pf evaluates a single filter list, so its position spans - // directions). Numbering after the merge keeps a collapsed pair from leaving a - // gap. Foreign rules appended below live outside this anchor and keep Number 0. - rules = mergeFamilies(rules) - numberSequential(rules) - // Collapse each input/output twin into one DirAny rule. Numbering first keeps - // the surviving rows' sequential position intact. - rules = mergeDirections(rules) - rules = append(rules, f.listForeignRules(ctx)...) - return rules, nil + defer func() { _ = fd.Close() }() + + var lines []string + scanner := bufio.NewScanner(fd) + // pf.conf can carry a very long single line (e.g. a large table macro); raise + // the token cap well above the default 64 KB so such a line is not rejected. + scanner.Buffer(make([]byte, 0, 64*1024), 64*1024*1024) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} + +// writeFileLines atomically replaces path with the provided lines by writing to +// a uniquely-named temp file in the same directory and renaming it into place. +// The original file's mode and ownership are preserved (defaulting to 0600 for a +// new file) so a rewrite never loosens restrictive permissions. +func (f *PF) writeFileLines(path string, lines []string) error { + af, err := newAtomicFile(path, 0600) + if err != nil { + return err + } + defer af.Abort() + w := bufio.NewWriter(af) + for _, line := range lines { + _, _ = fmt.Fprintln(w, line) + } + if err := w.Flush(); err != nil { + return err + } + return af.Commit() +} + +const ( + // PFType is the backend type string reported by PF.Type. + PFType = "pf" + // PFDefaultAnchor is the pf anchor name used when no rule prefix is supplied. + PFDefaultAnchor = "go_firewall" + // PFConf is the main pf configuration file. + PFConf = "/etc/pf.conf" +) + +// PF manages firewall rules through OpenBSD's Packet Filter (pf), used on both +// macOS and FreeBSD. To avoid disturbing rules owned by the base system, this +// backend keeps every rule it creates inside a private anchor (named after the +// rule prefix). The anchor is referenced from the main pf.conf so its rules are +// evaluated, but the rules themselves are loaded and read through pfctl scoped +// to the anchor. +type PF struct { + // anchor is the pf anchor this backend owns. + anchor string + // ensured records whether the anchor reference has been added to pf.conf + // this session so we only check/patch it once. + ensured bool + // natEnsured records the same for the nat/rdr anchor references, added + // lazily only when a NAT rule is first written. + natEnsured bool +} + +// ensureAnchor makes sure pf.conf references our anchor so that rules loaded +// into it are evaluated. If the reference is missing it is appended and pf.conf +// is reloaded. Filter anchors are evaluated in place, so appending keeps our +// rules after the base ruleset. +func (f *PF) ensureAnchor(ctx context.Context) error { + if f.ensured { + return nil + } + + data, err := f.readFileLines(PFConf) + if err != nil { + return err + } + + anchorRef := fmt.Sprintf(`anchor "%s"`, f.anchor) + for _, line := range data { + if strings.TrimSpace(line) == anchorRef { + f.ensured = true + return nil + } + } + + // Append the anchor reference and reload the main ruleset. + data = append(data, anchorRef) + if err := f.writeFileLines(PFConf, data); err != nil { + return err + } + if _, err := runCommand(ctx, "pfctl", "-f", PFConf); err != nil { + return fmt.Errorf("failed to reload pf.conf after adding anchor: %s", err) + } + f.ensured = true + return nil +} + +// pfFilterKeywords are the tokens that begin a pf filtering-section statement. +// Translation anchors (nat/rdr) must be declared before the first of these, so +// ensureNATAnchors inserts them at that boundary. pf.conf sections are strictly +// ordered options → normalization → queueing → translation → filtering, so the +// queueing keywords (altq/queue) are deliberately NOT included: they precede the +// translation section, and treating one as the boundary would splice the nat/rdr +// anchors ahead of the queueing statements, which pfctl -f rejects. +var pfFilterKeywords = map[string]bool{ + "pass": true, "block": true, "match": true, "anchor": true, + "antispoof": true, } // loadAnchor replaces the anchor's ruleset with the provided rules. pf requires @@ -1293,72 +1280,25 @@ func (f *PF) AddRule(ctx context.Context, zoneName string, r *Rule) error { return f.loadAnchor(ctx, natRaw, filterRaw) } -// AddRulesBatch appends every rule and reloads the anchor once, rather than one -// pfctl reload per rule. Rules that already exist are skipped. The reload is a -// single atomic pfctl transaction. It implements RuleBatcher. -func (f *PF) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { - if err := f.ensureAnchor(ctx); err != nil { - return err - } - - existing, filterRaw, err := f.anchorRules(ctx) - if err != nil { - return err - } - _, natRaw, err := f.anchorNATRules(ctx) - if err != nil { - return err - } - - for _, top := range rules { - // A DirAny rule fans out into an inbound rule plus its swapped outbound rule. - for _, r := range expandDirections(top) { - line, err := f.MarshalRule(r) - if err != nil { - return err - } - dup := false - for _, e := range existing { - if e != nil && e.Equal(r, true) { - dup = true - break - } - } - if dup { - continue - } - filterRaw = append(filterRaw, line) - existing = append(existing, r) +// filterAnchors maps each logical (merged) filter rule to its physical row index +// in the anchor, skipping opaque (nil) rows so an unmodeled foreign line occupying +// a physical slot does not consume a logical position. With no opaque rows it +// equals mergedFamilyAnchors. It backs the merged-position insert/move mapping. +func (f *PF) filterAnchors(rules []*Rule) []int { + phys := make([]int, 0, len(rules)) + modeled := make([]*Rule, 0, len(rules)) + for i, r := range rules { + if r == nil { + continue } + phys = append(phys, i) + modeled = append(modeled, r) } - return f.loadAnchor(ctx, natRaw, filterRaw) -} - -// ReplaceRulesBatch reloads the anchor so its filter rules are exactly rules, -// preserving any translation (nat/rdr) rules, in one pfctl transaction. It -// implements RuleBatcher. -func (f *PF) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { - if err := f.ensureAnchor(ctx); err != nil { - return err + anchors := mergedFamilyAnchors(modeled) + for k := range anchors { + anchors[k] = phys[anchors[k]] } - - _, natRaw, err := f.anchorNATRules(ctx) - if err != nil { - return err - } - - var filterRaw []string - for _, top := range rules { - // A DirAny rule fans out into an inbound rule plus its swapped outbound rule. - for _, r := range expandDirections(top) { - line, err := f.MarshalRule(r) - if err != nil { - return err - } - filterRaw = append(filterRaw, line) - } - } - return f.loadAnchor(ctx, natRaw, filterRaw) + return anchors } // InsertRule inserts rule before the given 1-based position. position <= 0 is @@ -1531,74 +1471,70 @@ func (f *PF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { return f.loadAnchor(ctx, natRaw, kept) } -// Backup captures the current filter and NAT rules managed by this backend. -func (f *PF) Backup(ctx context.Context, zoneName string) (*Backup, error) { - // Read the private anchor directly rather than GetRules: Restore refills only - // this anchor, so the backup must not pull in rules from the main ruleset or - // other anchors (they would be re-loaded into the wrong anchor on Restore). - rules, _, err := f.anchorRules(ctx) - if err != nil { - return nil, err +// compactNATRules is compactRules for NAT rules: it drops the opaque (nil) +// placeholder rows anchorNATRules keeps for unmodeled lines. +func (f *PF) compactNATRules(rules []*NATRule) []*NATRule { + out := make([]*NATRule, 0, len(rules)) + for _, r := range rules { + if r != nil { + out = append(out, r) + } } - natRules, _, err := f.anchorNATRules(ctx) - if err != nil { - return nil, err - } - // A Backup holds modeled rules ([]*Rule / []*NATRule), which cannot carry an - // unparseable anchor line, so drop the opaque placeholder rows here. - backup := &Backup{Rules: f.compactRules(rules), NATRules: f.compactNATRules(natRules)} - // pf has no default policy to capture (DefaultPolicy is false), so this only - // adds the pf tables a rule may reference. - if err := captureBackupState(ctx, f, zoneName, backup); err != nil { - return nil, err - } - return backup, nil + return out } -// Restore replaces the managed rules with the contents of a Backup. -func (f *PF) Restore(ctx context.Context, zoneName string, backup *Backup) error { - if backup == nil { - return fmt.Errorf("backup cannot be nil") - } - // Ensure the pf.conf anchor references exist before loading. When the backup - // carries NAT rules, the nat-anchor/rdr-anchor references must be present too - // (ensureNATAnchors also ensures the filter anchor); without them pf loads the - // translation rules into the anchor but never evaluates them, mirroring the - // AddNATRule/InsertNATRule paths. - if len(backup.NATRules) > 0 { - if err := f.ensureNATAnchors(ctx); err != nil { - return err +// listForeignNATRules returns best-effort nat/rdr rules loaded outside this +// backend's own anchor — the main ruleset and any other anchors. Unparseable +// lines are skipped, as in listForeignRules. +func (f *PF) listForeignNATRules(ctx context.Context) []*NATRule { + var rules []*NATRule + // table records where each foreign NAT rule came from ("" for the main ruleset, + // the anchor name otherwise); not ours, so HasPrefix stays false. + parse := func(out []string, table string) { + for _, line := range out { + line = strings.TrimSpace(line) + if line == "" { + continue + } + rule, perr := f.UnmarshalNATRule(line) + if perr != nil || rule == nil { + continue + } + rule.table = table + rules = append(rules, rule) } - } else if err := f.ensureAnchor(ctx); err != nil { - return err } - - // Recreate the pf tables a rule may reference (`
`) before loading the - // anchor. pf tables are global and independent of the anchor ruleset, so this - // creates or repopulates them (pfctl -T add) without disturbing the anchor. - if err := restoreBackupSets(ctx, f, backup, false); err != nil { - return err + if out, err := runCommand(ctx, "pfctl", "-sn"); err == nil { + parse(out, "") } - - filterLines := make([]string, 0, len(backup.Rules)) - for _, r := range backup.Rules { - line, err := f.MarshalRule(r) - if err != nil { - return err + if names, err := runCommand(ctx, "pfctl", "-s", "Anchors"); err == nil { + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" || name == f.anchor { + continue + } + if out, err := runCommand(ctx, "pfctl", "-a", name, "-sn"); err == nil { + parse(out, name) + } } - filterLines = append(filterLines, line) } + return rules +} - natLines := make([]string, 0, len(backup.NATRules)) - for _, r := range backup.NATRules { - line, err := f.MarshalNATRule(r) - if err != nil { - return err - } - natLines = append(natLines, line) +// GetNATRules returns the existing NAT rules from the zone. +func (f *PF) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { + rules, _, err = f.anchorNATRules(ctx) + if err != nil { + return nil, err } - - return f.loadAnchor(ctx, natLines, filterLines) + // Drop the opaque placeholder rows kept for unmodeled anchor lines. + rules = f.compactNATRules(rules) + // Merge families, then number the anchor's NAT rules as one ordered list so a + // collapsed pair leaves no gap; foreign NAT rules appended below keep Number 0. + rules = mergeNATFamilies(rules) + numberNATSequential(rules) + rules = append(rules, f.listForeignNATRules(ctx)...) + return rules, nil } // MarshalNATRule encodes a NAT rule as a pf rdr/nat rule line for our anchor. @@ -1685,145 +1621,76 @@ func (f *PF) MarshalNATRule(r *NATRule) (string, error) { return strings.Join(parts, " "), nil } -// UnmarshalNATRule decodes a single pf nat/rdr rule line as produced by -// `pfctl -a -sn`. -func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) { - tokens := strings.Fields(line) - if len(tokens) == 0 { - return nil, fmt.Errorf("empty rule") - } - - r := new(NATRule) - i := 0 - switch tokens[i] { - case "rdr": - r.Kind = DNAT - case "nat": - r.Kind = SNAT // Refined to Masquerade below if the target is dynamic. - default: - return nil, fmt.Errorf("unsupported nat action: %s", tokens[i]) - } - i++ - - for ; i < len(tokens); i++ { - switch tokens[i] { - case "pass", "quick", "log": - // Qualifiers with no bearing on our model. - case "all": - // pfctl prints `all` for `from any to any`. - case "round-robin", "random", "source-hash", "bitmask", "static-port", "sticky-address": - // Address-pool / port options pfctl appends to a nat rule; ignored. - case "on": - i++ - if i >= len(tokens) { - return nil, fmt.Errorf("missing interface value") - } - r.Interface = tokens[i] - case "inet": - r.Family = IPv4 - case "inet6": - r.Family = IPv6 - case "proto": - i++ - if i >= len(tokens) { - return nil, fmt.Errorf("missing protocol value") - } - r.Proto = GetProtocol(tokens[i]) - if r.Proto == ProtocolAny { - return nil, fmt.Errorf("unsupported protocol: %s", tokens[i]) - } - case "from": - val, neg, next, err := f.parseAddr(tokens, i+1) - if err != nil { - return nil, err - } - i = next - if val != "any" { - r.Source = neg + f.stripTable(val) - } - case "to": - val, neg, next, err := f.parseAddr(tokens, i+1) - if err != nil { - return nil, err - } - i = next - if val != "any" { - r.Destination = neg + f.stripTable(val) - } - case "port": - i++ - if i >= len(tokens) { - return nil, fmt.Errorf("missing port value") - } - if tokens[i] == "=" { - i++ - if i >= len(tokens) { - return nil, fmt.Errorf("missing port value") - } - } - specs, next, err := f.parsePorts(tokens, i) - if err != nil { - return nil, err - } - i = next - if len(specs) == 1 && specs[0].Start == specs[0].End { - r.Port = specs[0].Start - } else { - r.Ports = specs - } - case "->": - i++ - if i >= len(tokens) { - return nil, fmt.Errorf("missing nat target") - } - target := tokens[i] - if strings.HasPrefix(target, "(") { - // A dynamic interface address is masquerade. - r.Kind = Masquerade - } else { - r.ToAddress = target - // An optional `port N` gives the translation port. pfctl prints a - // well-known target port by its /etc/services name (80 -> http), just - // like a match port, so resolve it through lookupPort rather than a - // number-only parse — otherwise a named target port fails to parse and - // the whole rule is dropped from the snapshot. - if i+2 < len(tokens) && tokens[i+1] == "port" { - p, err := f.lookupPort(tokens[i+2]) - if err != nil { - return nil, fmt.Errorf("invalid nat target port %q", tokens[i+2]) - } - r.ToPort = p - i += 2 - } - } - default: - return nil, fmt.Errorf("unsupported token: %s", tokens[i]) +// translationBoundary returns the index of the first filtering statement in a +// pf.conf, which is where the nat/rdr translation anchors must be inserted (see +// pfFilterKeywords for why queueing keywords are excluded). When there is no +// filtering statement the boundary is the end of the file, so anchors are appended. +func (f *PF) translationBoundary(data []string) int { + for i, line := range data { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) > 0 && pfFilterKeywords[fields[0]] { + return i } } - - if r.Family == FamilyAny { - r.Family = r.impliedFamily() - } - if r.Kind == NATInvalid { - return nil, fmt.Errorf("no nat action was provided") - } - return r, nil + return len(data) } -// GetNATRules returns the existing NAT rules from the zone. -func (f *PF) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { - rules, _, err = f.anchorNATRules(ctx) - if err != nil { - return nil, err +// ensureNATAnchors inserts them at that boundary. pf.conf sections are strictly +// ordered options → normalization → queueing → translation → filtering, so the +// queueing keywords (altq/queue) are deliberately NOT included: they precede the +// translation section, and treating one as the boundary would splice the nat/rdr +// anchors ahead of the queueing statements, which pfctl -f rejects. +func (f *PF) ensureNATAnchors(ctx context.Context) error { + if f.natEnsured { + return nil } - // Drop the opaque placeholder rows kept for unmodeled anchor lines. - rules = f.compactNATRules(rules) - // Merge families, then number the anchor's NAT rules as one ordered list so a - // collapsed pair leaves no gap; foreign NAT rules appended below keep Number 0. - rules = mergeNATFamilies(rules) - numberNATSequential(rules) - rules = append(rules, f.listForeignNATRules(ctx)...) - return rules, nil + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + data, err := f.readFileLines(PFConf) + if err != nil { + return err + } + + natRef := fmt.Sprintf(`nat-anchor "%s"`, f.anchor) + rdrRef := fmt.Sprintf(`rdr-anchor "%s"`, f.anchor) + haveNat, haveRdr := false, false + for _, line := range data { + trimmed := strings.TrimSpace(line) + if trimmed == natRef { + haveNat = true + } + if trimmed == rdrRef { + haveRdr = true + } + } + insertAt := f.translationBoundary(data) + if haveNat && haveRdr { + f.natEnsured = true + return nil + } + + var add []string + if !haveRdr { + add = append(add, rdrRef) + } + if !haveNat { + add = append(add, natRef) + } + updated := make([]string, 0, len(data)+len(add)) + updated = append(updated, data[:insertAt]...) + updated = append(updated, add...) + updated = append(updated, data[insertAt:]...) + + if err := f.writeFileLines(PFConf, updated); err != nil { + return err + } + if _, err := runCommand(ctx, "pfctl", "-f", PFConf); err != nil { + return fmt.Errorf("failed to reload pf.conf after adding nat anchors: %s", err) + } + f.natEnsured = true + return nil } // AddNATRule adds a NAT rule to the zone. @@ -1861,6 +1728,25 @@ func (f *PF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error return f.loadAnchor(ctx, natRaw, filterRaw) } +// natAnchors is filterAnchors for NAT rules: it maps each logical (merged) NAT +// rule to its physical row index, skipping opaque (nil) rows. +func (f *PF) natAnchors(rules []*NATRule) []int { + phys := make([]int, 0, len(rules)) + modeled := make([]*NATRule, 0, len(rules)) + for i, r := range rules { + if r == nil { + continue + } + phys = append(phys, i) + modeled = append(modeled, r) + } + anchors := mergedNATFamilyAnchors(modeled) + for k := range anchors { + anchors[k] = phys[anchors[k]] + } + return anchors +} + // InsertNATRule inserts a NAT rule at the given 1-based position within the // anchor's NAT ruleset. position <= 0 is treated as 1; a position larger than the // current NAT rule count appends the rule. @@ -1943,38 +1829,74 @@ func (f *PF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) err return f.loadAnchor(ctx, kept, filterRaw) } -// Reload is a no-op; pf applies anchor changes immediately. -func (f *PF) Reload(ctx context.Context) error { - return nil -} - -// Close releases any resources held by the backend; pf holds none. -func (f *PF) Close(ctx context.Context) error { - return nil -} - -// Capabilities returns the set of features the pf backend can express. -func (f *PF) Capabilities() Capabilities { - return Capabilities{ - Output: true, - ICMPv6: true, - // pfctl expands a port list (`port { 80 443 }`) into one rule per port when - // it lists the ruleset, so a discrete multi-port rule does not round-trip as - // a single rule (a range, kept as one token, does). Report PortList as - // unsupported to reflect that; callers open several ports with a rule each. - PortList: false, - ConnState: false, - InterfaceMatch: true, - Logging: true, - RateLimit: true, - ConnLimit: true, - NAT: true, - RuleOrdering: true, - DefaultPolicy: false, - RuleCounters: true, - AddressSets: true, - Comments: true, +// Backup captures the current filter and NAT rules managed by this backend. +func (f *PF) Backup(ctx context.Context, zoneName string) (*Backup, error) { + // Read the private anchor directly rather than GetRules: Restore refills only + // this anchor, so the backup must not pull in rules from the main ruleset or + // other anchors (they would be re-loaded into the wrong anchor on Restore). + rules, _, err := f.anchorRules(ctx) + if err != nil { + return nil, err } + natRules, _, err := f.anchorNATRules(ctx) + if err != nil { + return nil, err + } + // A Backup holds modeled rules ([]*Rule / []*NATRule), which cannot carry an + // unparseable anchor line, so drop the opaque placeholder rows here. + backup := &Backup{Rules: f.compactRules(rules), NATRules: f.compactNATRules(natRules)} + // pf has no default policy to capture (DefaultPolicy is false), so this only + // adds the pf tables a rule may reference. + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *PF) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + // Ensure the pf.conf anchor references exist before loading. When the backup + // carries NAT rules, the nat-anchor/rdr-anchor references must be present too + // (ensureNATAnchors also ensures the filter anchor); without them pf loads the + // translation rules into the anchor but never evaluates them, mirroring the + // AddNATRule/InsertNATRule paths. + if len(backup.NATRules) > 0 { + if err := f.ensureNATAnchors(ctx); err != nil { + return err + } + } else if err := f.ensureAnchor(ctx); err != nil { + return err + } + + // Recreate the pf tables a rule may reference (`
`) before loading the + // anchor. pf tables are global and independent of the anchor ruleset, so this + // creates or repopulates them (pfctl -T add) without disturbing the anchor. + if err := restoreBackupSets(ctx, f, backup, false); err != nil { + return err + } + + filterLines := make([]string, 0, len(backup.Rules)) + for _, r := range backup.Rules { + line, err := f.MarshalRule(r) + if err != nil { + return err + } + filterLines = append(filterLines, line) + } + + natLines := make([]string, 0, len(backup.NATRules)) + for _, r := range backup.NATRules { + line, err := f.MarshalNATRule(r) + if err != nil { + return err + } + natLines = append(natLines, line) + } + + return f.loadAnchor(ctx, natLines, filterLines) } // GetDefaultPolicy is unsupported; pf exposes no default policy in this model. @@ -1989,6 +1911,14 @@ func (f *PF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *Defa // --- address sets (pf tables) ----------------------------------------------- +// isMissingTableErr reports whether a pfctl error means the table does not +// exist. pfctl prints "pfctl: Table does not exist." for a missing table across +// its -T subcommands (show/kill/delete), so every idempotent table operation keys +// on this same string rather than each guessing at the wording. +func (f *PF) isMissingTableErr(err error) bool { + return err != nil && strings.Contains(err.Error(), "does not exist") +} + // tableFamily infers a table's family from its entries (defaulting to IPv4). func (f *PF) tableFamily(entries []string) Family { for _, e := range entries { @@ -1999,35 +1929,6 @@ func (f *PF) tableFamily(entries []string) Family { return IPv4 } -// GetAddressSets returns the address sets (pf tables) managed by this backend. -func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { - out, err := runCommand(ctx, "pfctl", "-s", "Tables") - if err != nil { - return nil, nil - } - var result []*AddressSet - for _, line := range out { - name := strings.TrimSpace(line) - if name == "" { - continue - } - set, err := f.getAddressSet(ctx, name) - if err != nil || set == nil { - continue - } - result = append(result, set) - } - return result, nil -} - -// isMissingTableErr reports whether a pfctl error means the table does not -// exist. pfctl prints "pfctl: Table does not exist." for a missing table across -// its -T subcommands (show/kill/delete), so every idempotent table operation keys -// on this same string rather than each guessing at the wording. -func (f *PF) isMissingTableErr(err error) bool { - return err != nil && strings.Contains(err.Error(), "does not exist") -} - func (f *PF) getAddressSet(ctx context.Context, name string) (*AddressSet, error) { out, err := runCommand(ctx, "pfctl", "-t", name, "-T", "show") if err != nil { @@ -2049,6 +1950,27 @@ func (f *PF) getAddressSet(ctx context.Context, name string) (*AddressSet, error return &AddressSet{Name: name, Family: f.tableFamily(entries), Entries: entries}, nil } +// GetAddressSets returns the address sets (pf tables) managed by this backend. +func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + out, err := runCommand(ctx, "pfctl", "-s", "Tables") + if err != nil { + return nil, nil + } + var result []*AddressSet + for _, line := range out { + name := strings.TrimSpace(line) + if name == "" { + continue + } + set, err := f.getAddressSet(ctx, name) + if err != nil || set == nil { + continue + } + result = append(result, set) + } + return result, nil +} + // GetAddressSet returns a single address set by name, or an error if it does not exist. func (f *PF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { set, err := f.getAddressSet(ctx, name) @@ -2117,3 +2039,81 @@ func (f *PF) RemoveAddressSetEntry(ctx context.Context, name, entry string) erro } return err } + +// Reload is a no-op; pf applies anchor changes immediately. +func (f *PF) Reload(ctx context.Context) error { + return nil +} + +// Close releases any resources held by the backend; pf holds none. +func (f *PF) Close(ctx context.Context) error { + return nil +} + +// AddRulesBatch appends every rule and reloads the anchor once, rather than one +// pfctl reload per rule. Rules that already exist are skipped. The reload is a +// single atomic pfctl transaction. It implements RuleBatcher. +func (f *PF) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + existing, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + _, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + + for _, top := range rules { + // A DirAny rule fans out into an inbound rule plus its swapped outbound rule. + for _, r := range expandDirections(top) { + line, err := f.MarshalRule(r) + if err != nil { + return err + } + dup := false + for _, e := range existing { + if e != nil && e.Equal(r, true) { + dup = true + break + } + } + if dup { + continue + } + filterRaw = append(filterRaw, line) + existing = append(existing, r) + } + } + return f.loadAnchor(ctx, natRaw, filterRaw) +} + +// ReplaceRulesBatch reloads the anchor so its filter rules are exactly rules, +// preserving any translation (nat/rdr) rules, in one pfctl transaction. It +// implements RuleBatcher. +func (f *PF) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + _, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + + var filterRaw []string + for _, top := range rules { + // A DirAny rule fans out into an inbound rule plus its swapped outbound rule. + for _, r := range expandDirections(top) { + line, err := f.MarshalRule(r) + if err != nil { + return err + } + filterRaw = append(filterRaw, line) + } + } + return f.loadAnchor(ctx, natRaw, filterRaw) +} diff --git a/scripts/refactor_order.py b/scripts/refactor_order.py index 989d15e..12171fa 100644 --- a/scripts/refactor_order.py +++ b/scripts/refactor_order.py @@ -25,7 +25,7 @@ import sys # 1: receiver variable (None for a plain function) # 2: receiver type (None for a plain function) # 3: function name -DECL_RE = re.compile(r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\(") +DECL_RE = re.compile(r"^func\s+(?:\((?:(\w+)\s+)?\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\(") def split_blocks(text): diff --git a/ufw_linux.go b/ufw_linux.go index 399bc39..1b6420e 100644 --- a/ufw_linux.go +++ b/ufw_linux.go @@ -137,31 +137,105 @@ func (f *UFW) Type() string { return UFWType } +// Capabilities reports the features this backend supports. +func (f *UFW) 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: false, + AddressSets: true, + Comments: true, + } +} + +// --- default policy --------------------------------------------------------- + // GetZone reports no zone; ufw has no zone support. func (f *UFW) GetZone(ctx context.Context, iface string) (zoneName string, err error) { return "", nil } -// zeroNet returns the zero-network ("any") CIDR for a family, defaulting to -// the IPv4 form when the family is unspecified. -func (f *UFW) zeroNet(fam Family) string { - if fam == IPv6 { - return "::/0" +// ipTablesChain maps a ufw iptables chain to a rule direction, reporting +// whether it is one this backend surfaces. Internal chains (logging, not-local, +// skip-to-policy) are not represented and return ok=false. Both the IPv4 (`ufw-*`) +// and IPv6 (`ufw6-*`) chain names are accepted, since before6.rules declares its +// chains with the `ufw6-` prefix. +func (f *UFW) ipTablesChain(chain string) (dir Direction, ok bool) { + switch chain { + case "ufw-before-input", "ufw-after-input", "ufw-user-input", + "ufw6-before-input", "ufw6-after-input", "ufw6-user-input": + return DirInput, true + case "ufw-before-output", "ufw-after-output", "ufw-user-output", + "ufw6-before-output", "ufw6-after-output", "ufw6-user-output": + return DirOutput, true + case "ufw-before-forward", "ufw-after-forward", "ufw-user-forward", + "ufw6-before-forward", "ufw6-after-forward", "ufw6-user-forward": + return DirForward, true } - return "0.0.0.0/0" + return DirInput, false } -// anyAddr returns the address literal used to stand in for an unspecified -// endpoint when ufw's grammar forces one. A concrete family uses its -// zero-network CIDR; a family-agnostic rule uses the literal "any" so ufw -// installs both the IPv4 and IPv6 rule — a zero-network CIDR (which is -// family-specific) would silently pin the rule to a single family and break the -// round-trip back to FamilyAny. -func (f *UFW) anyAddr(fam Family) string { - if fam == FamilyAny { - return "any" +// ParseIPTablesRules parses a ufw before/after rules file, which is in +// iptables-restore format using ufw's own chains. Each `-A ...` line on +// an input/output/forward chain is reparsed with the iptables rulespec parser; +// lines whose match or action this model cannot represent are skipped. +func (f *UFW) ParseIPTablesRules(filePath string, family Family) (rules []*Rule, err error) { + fd, err := os.Open(filePath) + if err != nil { + // A missing iptables rules file simply contributes no rules. + if os.IsNotExist(err) { + return nil, nil + } + return nil, err } - return f.zeroNet(fam) + defer func() { _ = fd.Close() }() + + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || line[0] == '#' || line[0] == '*' || line[0] == ':' || line == "COMMIT" { + continue + } + + fields := strings.Fields(line) + if len(fields) < 3 || (fields[0] != "-A" && fields[0] != "--append") { + continue + } + dir, ok := f.ipTablesChain(fields[1]) + if !ok { + continue + } + + // Rewrite the ufw chain to its INPUT/OUTPUT/FORWARD equivalent and reuse the + // iptables parser. + spec := "-A " + iptChainForDirection(dir) + " " + strings.Join(fields[2:], " ") + rule, perr := unmarshalIPTablesRule(spec, family) + if perr != nil { + continue + } + // Strip the prefix so only the user-facing comment surfaces, and flag + // whether the prefix marked this as one of our rules. + text, hasPrefix := prefixedComment(f.rulePrefix, rule.Comment) + rule.Comment = text + rule.HasPrefix = hasPrefix + rules = append(rules, rule) + } + if err := scanner.Err(); err != nil { + return nil, err + } + // A logged rule is a LOG line followed by its action line; fold the pair + // back into one logical rule. + return coalesceLoggedRules(rules), nil } // parseAddr validates a ufw tuple address token (an IP or CIDR) and returns @@ -326,6 +400,201 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) { return } +// parseTupleRows scans a ufw rules file and returns one entry per `### tuple ###` +// line, in file order: the parsed rule, or nil for a non-empty tuple this backend +// does not model (one that fails to parse). ufw counts every tuple in its own +// numbered list, so keeping such rows as nil lets callers map a representable rule +// to its true physical position. Only a tuple whose body is empty after stripping +// the comment is dropped without occupying a slot. +func (f *UFW) parseTupleRows(filePath string, family Family) ([]*Rule, error) { + fd, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer func() { _ = fd.Close() }() + + var rows []*Rule + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + // Get the line. + line := scanner.Text() + + // Ignore non-tuple lines. + tuplePrefix := "### tuple ### " + if !strings.HasPrefix(line, tuplePrefix) { + continue + } + line = strings.TrimPrefix(line, tuplePrefix) + + // Remove comments. + ci := strings.IndexByte(line, '#') + if ci >= 0 { + line = line[:ci] + } + // A trailing ` comment=` carries the ufw rule comment, hex-encoded + // UTF-8. Capture and decode it, then strip it before parsing the tuple. + var comment string + if ci = strings.LastIndex(line, " comment="); ci >= 0 { + hexVal := strings.TrimSpace(line[ci+len(" comment="):]) + if b, derr := hex.DecodeString(hexVal); derr == nil { + comment = string(b) + } + line = line[:ci] + } + + // Trim spaces. + line = strings.TrimSpace(line) + + // Ignore zero lines. + if len(line) == 0 { + continue + } + + // Parse rule. A tuple this backend cannot model (e.g. a route/forward rule) + // is kept as a nil row so it still occupies a physical position. + rule, err := f.UnmarshalRule(line, family) + if err != nil { + rows = append(rows, nil) + continue + } + // Strip the prefix so only the user-facing comment surfaces, and flag + // whether the prefix marked this as one of our rules. + text, hasPrefix := prefixedComment(f.rulePrefix, comment) + rule.Comment = text + rule.HasPrefix = hasPrefix + rows = append(rows, rule) + } + if serr := scanner.Err(); serr != nil { + return nil, serr + } + return rows, nil +} + +// ParseRules reads a ufw rules file and returns the rules it models, in file order. +func (f *UFW) ParseRules(filePath string, family Family) (rules []*Rule, err error) { + rows, err := f.parseTupleRows(filePath, family) + if err != nil { + return nil, err + } + for _, r := range rows { + if r != nil { + rules = append(rules, r) + } + } + return rules, nil +} + +// GetRules reports it) to the 1-based position ufw's own numbered list uses for +// `ufw insert`. GetRules merges IPv4/IPv6 tuple pairs, but ufw numbers every IPv4 +// tuple then every IPv6 tuple without merging, so the two index spaces diverge +// once a dual-family rule and a single-family rule coexist. The pre-merge tuple +// order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's native order, so +// the merged position's anchor row in that list is its native position. A position +// past the last logical rule maps past the native count, which ufw rejects and +func (f *UFW) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + // Parse IPv4 user rules. + tupleRules, err := f.ParseRules(UFWIPv4, IPv4) + if err != nil { + return nil, err + } + + // Parse IPv6 user rules. + v6Rules, err := f.ParseRules(UFWIPv6, IPv6) + if err != nil { + return nil, err + } + tupleRules = append(tupleRules, v6Rules...) + + // Number the tuple rules as one ordered list: `ufw insert` positions within a + // single numbered list spanning both families. The raw before.rules entries read + // below sit outside that list, so they keep Number 0. + numberSequential(tupleRules) + rules = append(rules, tupleRules...) + + // Parse the before.rules iptables files, which carry ICMP and other rules the + // user-rule tuple format cannot express. Only the before.rules files are read: + // this backend writes and removes raw rules exclusively there (see + // iptablesFilesFor), so reading after.rules too would surface rules it cannot + // remove — Restore then re-added them into before.rules, duplicating them. + iptablesFiles := []struct { + path string + family Family + }{ + {UFWBefore, IPv4}, + {UFWBefore6, IPv6}, + } + for _, ff := range iptablesFiles { + iptablesRules, ferr := f.ParseIPTablesRules(ff.path, ff.family) + if ferr != nil { + return nil, ferr + } + rules = append(rules, iptablesRules...) + } + + // Merge rules across families, then renumber the ufw-list rules so a collapsed + // v4/v6 pair leaves no gap. Only the numbered tuple rules (Number != 0) are + // resequenced; before.rules entries kept Number 0 and stay outside the list. + rules = mergeFamilies(rules) + n := 0 + for _, r := range rules { + if r.Number != 0 { + n++ + r.Number = n + } + } + // Collapse each input/output twin into one DirAny rule after numbering, so the + // surviving tuple rows keep their list position. + rules = mergeDirections(rules) + return +} + +// zeroNet returns the zero-network ("any") CIDR for a family, defaulting to +// the IPv4 form when the family is unspecified. +func (f *UFW) zeroNet(fam Family) string { + if fam == IPv6 { + return "::/0" + } + return "0.0.0.0/0" +} + +// anyAddr returns the address literal used to stand in for an unspecified +// endpoint when ufw's grammar forces one. A concrete family uses its +// zero-network CIDR; a family-agnostic rule uses the literal "any" so ufw +// installs both the IPv4 and IPv6 rule — a zero-network CIDR (which is +// family-specific) would silently pin the rule to a single family and break the +// round-trip back to FamilyAny. +func (f *UFW) anyAddr(fam Family) string { + if fam == FamilyAny { + return "any" + } + return f.zeroNet(fam) +} + +// isNativeLimit reports whether r is expressible as ufw's built-in `limit` +// action: an accept carrying exactly ufw's fixed rate (6 connections per 30s, +// modeled as 12/minute burst 6) and no other modifier the tuple form cannot +// hold. UnmarshalRule decodes a `limit` tuple into this exact shape, so it is the +// signature that round-trips through the CLI rather than the before.rules files. +func (f *UFW) isNativeLimit(r *Rule) bool { + // Logging is allowed: `ufw limit log ...` writes a `limit_log` tuple, which + // UnmarshalRule decodes back into this same shape with Log set. Excluding + // logged limits would route such a rule to the before.rules files even though + // it lives in user.rules, leaving it unremovable there and duplicating it on + // Restore. A custom LogPrefix still cannot be expressed in a tuple, so a limit + // carrying one stays false here and is routed to before.rules (which can). + return r.Action == Accept && r.ConnLimit == nil && r.State == 0 && r.LogPrefix == "" && + r.RateLimit != nil && *r.RateLimit == RateLimit{Rate: 12, Unit: PerMinute, Burst: 6} +} + +// protoNeedsRaw reports whether a protocol cannot be expressed through ufw's +// CLI/tuple format and must instead be written as a raw before.rules rule. ufw's +// supported_protocols list (src/util.py) carries tcp, udp, esp, ah and gre +// natively, so only ICMP/ICMPv6 and SCTP — which ufw does not accept — go through +// the iptables rules files. +func (f *UFW) protoNeedsRaw(p Protocol) bool { + return p.IsICMP() || p == SCTP +} + // MarshalRule encodes a firewall rule into a ufw rulespec. func (f *UFW) MarshalRule(r *Rule) (string, error) { // Features this backend cannot express in its rule model are rejected up @@ -530,277 +799,11 @@ func (f *UFW) MarshalRule(r *Rule) (string, error) { return strings.Join(parts, " "), nil } -// parseTupleRows scans a ufw rules file and returns one entry per `### tuple ###` -// line, in file order: the parsed rule, or nil for a non-empty tuple this backend -// does not model (one that fails to parse). ufw counts every tuple in its own -// numbered list, so keeping such rows as nil lets callers map a representable rule -// to its true physical position. Only a tuple whose body is empty after stripping -// the comment is dropped without occupying a slot. -func (f *UFW) parseTupleRows(filePath string, family Family) ([]*Rule, error) { - fd, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer func() { _ = fd.Close() }() - - var rows []*Rule - scanner := bufio.NewScanner(fd) - for scanner.Scan() { - // Get the line. - line := scanner.Text() - - // Ignore non-tuple lines. - tuplePrefix := "### tuple ### " - if !strings.HasPrefix(line, tuplePrefix) { - continue - } - line = strings.TrimPrefix(line, tuplePrefix) - - // Remove comments. - ci := strings.IndexByte(line, '#') - if ci >= 0 { - line = line[:ci] - } - // A trailing ` comment=` carries the ufw rule comment, hex-encoded - // UTF-8. Capture and decode it, then strip it before parsing the tuple. - var comment string - if ci = strings.LastIndex(line, " comment="); ci >= 0 { - hexVal := strings.TrimSpace(line[ci+len(" comment="):]) - if b, derr := hex.DecodeString(hexVal); derr == nil { - comment = string(b) - } - line = line[:ci] - } - - // Trim spaces. - line = strings.TrimSpace(line) - - // Ignore zero lines. - if len(line) == 0 { - continue - } - - // Parse rule. A tuple this backend cannot model (e.g. a route/forward rule) - // is kept as a nil row so it still occupies a physical position. - rule, err := f.UnmarshalRule(line, family) - if err != nil { - rows = append(rows, nil) - continue - } - // Strip the prefix so only the user-facing comment surfaces, and flag - // whether the prefix marked this as one of our rules. - text, hasPrefix := prefixedComment(f.rulePrefix, comment) - rule.Comment = text - rule.HasPrefix = hasPrefix - rows = append(rows, rule) - } - if serr := scanner.Err(); serr != nil { - return nil, serr - } - return rows, nil -} - -// ParseRules reads a ufw rules file and returns the rules it models, in file order. -func (f *UFW) ParseRules(filePath string, family Family) (rules []*Rule, err error) { - rows, err := f.parseTupleRows(filePath, family) - if err != nil { - return nil, err - } - for _, r := range rows { - if r != nil { - rules = append(rules, r) - } - } - return rules, nil -} - -// ipTablesChain maps a ufw iptables chain to a rule direction, reporting -// whether it is one this backend surfaces. Internal chains (logging, not-local, -// skip-to-policy) are not represented and return ok=false. Both the IPv4 (`ufw-*`) -// and IPv6 (`ufw6-*`) chain names are accepted, since before6.rules declares its -// chains with the `ufw6-` prefix. -func (f *UFW) ipTablesChain(chain string) (dir Direction, ok bool) { - switch chain { - case "ufw-before-input", "ufw-after-input", "ufw-user-input", - "ufw6-before-input", "ufw6-after-input", "ufw6-user-input": - return DirInput, true - case "ufw-before-output", "ufw-after-output", "ufw-user-output", - "ufw6-before-output", "ufw6-after-output", "ufw6-user-output": - return DirOutput, true - case "ufw-before-forward", "ufw-after-forward", "ufw-user-forward", - "ufw6-before-forward", "ufw6-after-forward", "ufw6-user-forward": - return DirForward, true - } - return DirInput, false -} - -// ParseIPTablesRules parses a ufw before/after rules file, which is in -// iptables-restore format using ufw's own chains. Each `-A ...` line on -// an input/output/forward chain is reparsed with the iptables rulespec parser; -// lines whose match or action this model cannot represent are skipped. -func (f *UFW) ParseIPTablesRules(filePath string, family Family) (rules []*Rule, err error) { - fd, err := os.Open(filePath) - if err != nil { - // A missing iptables rules file simply contributes no rules. - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - defer func() { _ = fd.Close() }() - - scanner := bufio.NewScanner(fd) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || line[0] == '#' || line[0] == '*' || line[0] == ':' || line == "COMMIT" { - continue - } - - fields := strings.Fields(line) - if len(fields) < 3 || (fields[0] != "-A" && fields[0] != "--append") { - continue - } - dir, ok := f.ipTablesChain(fields[1]) - if !ok { - continue - } - - // Rewrite the ufw chain to its INPUT/OUTPUT/FORWARD equivalent and reuse the - // iptables parser. - spec := "-A " + iptChainForDirection(dir) + " " + strings.Join(fields[2:], " ") - rule, perr := unmarshalIPTablesRule(spec, family) - if perr != nil { - continue - } - // Strip the prefix so only the user-facing comment surfaces, and flag - // whether the prefix marked this as one of our rules. - text, hasPrefix := prefixedComment(f.rulePrefix, rule.Comment) - rule.Comment = text - rule.HasPrefix = hasPrefix - rules = append(rules, rule) - } - if err := scanner.Err(); err != nil { - return nil, err - } - // A logged rule is a LOG line followed by its action line; fold the pair - // back into one logical rule. - return coalesceLoggedRules(rules), nil -} - -// GetRules returns the current filter rules for the zone. -func (f *UFW) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { - // Parse IPv4 user rules. - tupleRules, err := f.ParseRules(UFWIPv4, IPv4) - if err != nil { - return nil, err - } - - // Parse IPv6 user rules. - v6Rules, err := f.ParseRules(UFWIPv6, IPv6) - if err != nil { - return nil, err - } - tupleRules = append(tupleRules, v6Rules...) - - // Number the tuple rules as one ordered list: `ufw insert` positions within a - // single numbered list spanning both families. The raw before.rules entries read - // below sit outside that list, so they keep Number 0. - numberSequential(tupleRules) - rules = append(rules, tupleRules...) - - // Parse the before.rules iptables files, which carry ICMP and other rules the - // user-rule tuple format cannot express. Only the before.rules files are read: - // this backend writes and removes raw rules exclusively there (see - // iptablesFilesFor), so reading after.rules too would surface rules it cannot - // remove — Restore then re-added them into before.rules, duplicating them. - iptablesFiles := []struct { - path string - family Family - }{ - {UFWBefore, IPv4}, - {UFWBefore6, IPv6}, - } - for _, ff := range iptablesFiles { - iptablesRules, ferr := f.ParseIPTablesRules(ff.path, ff.family) - if ferr != nil { - return nil, ferr - } - rules = append(rules, iptablesRules...) - } - - // Merge rules across families, then renumber the ufw-list rules so a collapsed - // v4/v6 pair leaves no gap. Only the numbered tuple rules (Number != 0) are - // resequenced; before.rules entries kept Number 0 and stay outside the list. - rules = mergeFamilies(rules) - n := 0 - for _, r := range rules { - if r.Number != 0 { - n++ - r.Number = n - } - } - // Collapse each input/output twin into one DirAny rule after numbering, so the - // surviving tuple rows keep their list position. - rules = mergeDirections(rules) - return -} - -// needsIPTablesRules reports whether a rule must be written as raw iptables -// rules rather than through ufw's command line. The ufw CLI and its user.rules -// tuple format cannot express ICMP/SCTP, a connection-state match, a custom log -// prefix or a rate/connection limit, but the before.rules files can. Plain -// logging (no custom prefix) is expressed natively with ufw's `log` keyword, so -// it stays on the CLI path. -func (f *UFW) needsIPTablesRules(r *Rule) bool { - if f.protoNeedsRaw(r.Proto) || r.State != 0 || (r.Log && r.LogPrefix != "") || r.ConnLimit != nil { - return true - } - // ufw's tuple format takes only addresses in from/to; an ipset reference is - // written as a raw before.rules rule (`-m set --match-set`) instead. - if isSetRef(r.Source) || isSetRef(r.Destination) { - return true - } - // ufw's tuple grammar has no address negation, but before.rules can express it - // as `iptables ! -s/-d`, so a negated plain address routes there rather than - // being rejected. (A negated ipset reference is already covered above.) - if neg, _ := splitAddrNeg(r.Source); neg { - return true - } - if neg, _ := splitAddrNeg(r.Destination); neg { - return true - } - // ufw's built-in `limit` action is expressed through the CLI/user.rules, so a - // rule carrying exactly that rate stays on the tuple path; any other rate - // limit can only be written as raw iptables in the before.rules files. - if r.RateLimit != nil && !f.isNativeLimit(r) { - return true - } - return false -} - -// isNativeLimit reports whether r is expressible as ufw's built-in `limit` -// action: an accept carrying exactly ufw's fixed rate (6 connections per 30s, -// modeled as 12/minute burst 6) and no other modifier the tuple form cannot -// hold. UnmarshalRule decodes a `limit` tuple into this exact shape, so it is the -// signature that round-trips through the CLI rather than the before.rules files. -func (f *UFW) isNativeLimit(r *Rule) bool { - // Logging is allowed: `ufw limit log ...` writes a `limit_log` tuple, which - // UnmarshalRule decodes back into this same shape with Log set. Excluding - // logged limits would route such a rule to the before.rules files even though - // it lives in user.rules, leaving it unremovable there and duplicating it on - // Restore. A custom LogPrefix still cannot be expressed in a tuple, so a limit - // carrying one stays false here and is routed to before.rules (which can). - return r.Action == Accept && r.ConnLimit == nil && r.State == 0 && r.LogPrefix == "" && - r.RateLimit != nil && *r.RateLimit == RateLimit{Rate: 12, Unit: PerMinute, Burst: 6} -} - -// protoNeedsRaw reports whether a protocol cannot be expressed through ufw's -// CLI/tuple format and must instead be written as a raw before.rules rule. ufw's -// supported_protocols list (src/util.py) carries tcp, udp, esp, ah and gre -// natively, so only ICMP/ICMPv6 and SCTP — which ufw does not accept — go through -// the iptables rules files. -func (f *UFW) protoNeedsRaw(p Protocol) bool { - return p.IsICMP() || p == SCTP +// commentFor returns the comment text ufw should tag a rule with: the configured +// prefix carried alongside the user-supplied comment (prefix + " " + comment), so +// rules this library creates stay identifiable. +func (f *UFW) commentFor(r *Rule) string { + return combineComment(f.rulePrefix, r.Comment) } // rewriteToChain rewrites an iptables `-A INPUT/OUTPUT ...` line to use ufw's @@ -845,20 +848,6 @@ func (f *UFW) marshalIPTablesLines(r *Rule, family Family) ([]string, error) { return out, nil } -// iptablesFilesFor returns the before.rules file(s) a rule applies to. An ICMP -// protocol pins the family; a family-agnostic rule (e.g. a bare state match) -// touches both the IPv4 and IPv6 files. -func (f *UFW) iptablesFilesFor(r *Rule) []string { - switch r.impliedFamily() { - case IPv4: - return []string{UFWBefore} - case IPv6: - return []string{UFWBefore6} - default: - return []string{UFWBefore, UFWBefore6} - } -} - // parseIPTablesLine parses a raw before.rules line into the rule it represents // (one line, so a LOG line yields a rule with Log set and no action), reporting // whether the line is an input/output iptables rule this model surfaces. @@ -991,6 +980,20 @@ func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove return true, f.writeIPTablesRulesFile(path, out) } +// iptablesFilesFor returns the before.rules file(s) a rule applies to. An ICMP +// protocol pins the family; a family-agnostic rule (e.g. a bare state match) +// touches both the IPv4 and IPv6 files. +func (f *UFW) iptablesFilesFor(r *Rule) []string { + switch r.impliedFamily() { + case IPv4: + return []string{UFWBefore} + case IPv6: + return []string{UFWBefore6} + default: + return []string{UFWBefore, UFWBefore6} + } +} + // editIPTablesRules applies an add/remove across every before.rules file the rule // touches, recording whether a reload is needed. func (f *UFW) editIPTablesRules(r *Rule, remove bool) error { @@ -1010,7 +1013,39 @@ func (f *UFW) editIPTablesRules(r *Rule, remove bool) error { return nil } -// AddRule adds a filter rule to the zone. +// needsIPTablesRules reports whether a rule must be written as raw iptables +// rules rather than through ufw's command line. The ufw CLI and its user.rules +// tuple format cannot express ICMP/SCTP, a connection-state match, a custom log +// prefix or a rate/connection limit, but the before.rules files can. Plain +// logging (no custom prefix) is expressed natively with ufw's `log` keyword, so +// it stays on the CLI path. +func (f *UFW) needsIPTablesRules(r *Rule) bool { + if f.protoNeedsRaw(r.Proto) || r.State != 0 || (r.Log && r.LogPrefix != "") || r.ConnLimit != nil { + return true + } + // ufw's tuple format takes only addresses in from/to; an ipset reference is + // written as a raw before.rules rule (`-m set --match-set`) instead. + if isSetRef(r.Source) || isSetRef(r.Destination) { + return true + } + // ufw's tuple grammar has no address negation, but before.rules can express it + // as `iptables ! -s/-d`, so a negated plain address routes there rather than + // being rejected. (A negated ipset reference is already covered above.) + if neg, _ := splitAddrNeg(r.Source); neg { + return true + } + if neg, _ := splitAddrNeg(r.Destination); neg { + return true + } + // ufw's built-in `limit` action is expressed through the CLI/user.rules, so a + // rule carrying exactly that rate stays on the tuple path; any other rate + // limit can only be written as raw iptables in the before.rules files. + if r.RateLimit != nil && !f.isNativeLimit(r) { + return true + } + return false +} + // ruleArgs builds the argument list for a ufw rule command: the optional // command verb tokens (e.g. {"prepend"}, {"insert", "3"}, {"delete"}, or none for // a plain tail append) followed by the marshaled rule spec split into tokens. A @@ -1027,6 +1062,7 @@ func (f *UFW) ruleArgs(r *Rule, verb []string, spec string) []string { return args } +// AddRule adds a filter rule to the zone. func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error { // A DirAny rule fans out into an inbound tuple plus its role-swapped outbound // tuple; add each concrete-direction half (either may route to before.rules). @@ -1061,34 +1097,23 @@ func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error { return err } -// commentFor returns the comment text ufw should tag a rule with: the configured -// prefix carried alongside the user-supplied comment (prefix + " " + comment), so -// rules this library creates stay identifiable. -func (f *UFW) commentFor(r *Rule) string { - return combineComment(f.rulePrefix, r.Comment) -} - -// nativeInsertPosition maps a 1-based merged position (a rule's Number, as -// GetRules reports it) to the 1-based position ufw's own numbered list uses for -// `ufw insert`. GetRules merges IPv4/IPv6 tuple pairs, but ufw numbers every IPv4 -// tuple then every IPv6 tuple without merging, so the two index spaces diverge -// once a dual-family rule and a single-family rule coexist. The pre-merge tuple -// order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's native order, so -// the merged position's anchor row in that list is its native position. A position -// past the last logical rule maps past the native count, which ufw rejects and -// InsertRule appends instead. -func (f *UFW) nativeInsertPosition(position int) (int, error) { - v4, err := f.parseTupleRows(UFWIPv4, IPv4) +// appendRule adds a rule at the end of ufw's numbered list with a plain +// `ufw ` (ufw appends a non-inserted rule). It mirrors AddRule but does not +// use `ufw prepend`, so callers that need a tail append — InsertRule past the end, +// and MoveRule to the end — get end placement rather than front placement. Its +// only caller, InsertRule, already diverts raw rules to editIPTablesRules before +// reaching here, so r is always a native ufw rule at this point. +func (f *UFW) appendRule(ctx context.Context, r *Rule) error { + rule, err := f.MarshalRule(r) if err != nil { - return 0, err + return err } - v6, err := f.parseTupleRows(UFWIPv6, IPv6) - if err != nil { - return 0, err + args := f.ruleArgs(r, nil, rule) + if c := f.commentFor(r); c != "" { + args = append(args, "comment", c) } - // Physical order is every IPv4 tuple then every IPv6 tuple — ufw's own numbered - // order. - return f.nativeInsertPositionFromRows(append(v4, v6...), position), nil + _, err = runCommand(ctx, "ufw", args...) + return err } // nativeInsertPositionFromRows maps a 1-based merged position to ufw's 1-based @@ -1117,6 +1142,21 @@ func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int { return physPos[repIdx] } +// nativeInsertPosition maps a 1-based merged position (a rule's Number, as +func (f *UFW) nativeInsertPosition(position int) (int, error) { + v4, err := f.parseTupleRows(UFWIPv4, IPv4) + if err != nil { + return 0, err + } + v6, err := f.parseTupleRows(UFWIPv6, IPv6) + if err != nil { + return 0, err + } + // Physical order is every IPv4 tuple then every IPv6 tuple — ufw's own numbered + // order. + return f.nativeInsertPositionFromRows(append(v4, v6...), position), nil +} + // InsertRule inserts rule before the given 1-based position using `ufw insert`. // position <= 0 is treated as 1; a position larger than the current rule count // appends the rule (ufw itself rejects an out-of-range position, so that case @@ -1172,37 +1212,6 @@ func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r * return err } -// appendRule adds a rule at the end of ufw's numbered list with a plain -// `ufw ` (ufw appends a non-inserted rule). It mirrors AddRule but does not -// use `ufw prepend`, so callers that need a tail append — InsertRule past the end, -// and MoveRule to the end — get end placement rather than front placement. Its -// only caller, InsertRule, already diverts raw rules to editIPTablesRules before -// reaching here, so r is always a native ufw rule at this point. -func (f *UFW) appendRule(ctx context.Context, r *Rule) error { - rule, err := f.MarshalRule(r) - if err != nil { - return err - } - args := f.ruleArgs(r, nil, rule) - if c := f.commentFor(r); c != "" { - args = append(args, "comment", c) - } - _, err = runCommand(ctx, "ufw", args...) - return err -} - -// MoveRule repositions an existing rule. ufw has no native move verb, so a move -// is a positional delete-then-insert: the rule is removed and re-inserted at the -// requested slot. It is therefore not atomic — if the re-insert fails the rule is -// left removed. A position larger than the rule count moves the rule to the end -// (via InsertRule's append fallback). -func (f *UFW) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { - if err := f.RemoveRule(ctx, zoneName, r); err != nil { - return err - } - return f.InsertRule(ctx, zoneName, position, r) -} - // RemoveRule removes a filter rule from the zone. func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { // A DirAny target removes both its inbound and outbound tuple. @@ -1235,6 +1244,18 @@ func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { return nil } +// MoveRule repositions an existing rule. ufw has no native move verb, so a move +// is a positional delete-then-insert: the rule is removed and re-inserted at the +// requested slot. It is therefore not atomic — if the re-insert fails the rule is +// left removed. A position larger than the rule count moves the rule to the end +// (via InsertRule's append fallback). +func (f *UFW) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + if err := f.RemoveRule(ctx, zoneName, r); err != nil { + return err + } + return f.InsertRule(ctx, zoneName, position, r) +} + // natHelper returns an iptables backend scoped to ufw's before.rules files, so // the iptables nat-table machinery (marshal/parse/edit) can be reused: ufw's // before.rules is loaded through iptables-restore and takes a standard `*nat` @@ -1366,47 +1387,6 @@ func (f *UFW) Restore(ctx context.Context, zoneName string, backup *Backup) erro return applyBackupPolicy(ctx, f, zoneName, backup) } -// Reload re-applies edits to the iptables rules files; rules added through the -// ufw CLI apply immediately, but edits to those files only take effect after a -// reload. -func (f *UFW) Reload(ctx context.Context) error { - if f.iptablesRulesChanged { - if _, err := runCommand(ctx, "ufw", "reload"); err != nil { - return err - } - f.iptablesRulesChanged = false - } - return nil -} - -// Close releases any resources held by the backend. -func (f *UFW) Close(ctx context.Context) error { - return nil -} - -// Capabilities reports the features this backend supports. -func (f *UFW) 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: false, - AddressSets: true, - Comments: true, - } -} - -// --- default policy --------------------------------------------------------- - // policyKey is the /etc/default/ufw key for a direction's default policy. func (f *UFW) policyKey(d Direction) string { switch d { @@ -1457,16 +1437,23 @@ func (f *UFW) readPolicy() (*DefaultPolicy, error) { return policy, nil } -// set assigns the action for a direction on a DefaultPolicy. -func (p *DefaultPolicy) set(d Direction, a Action) { - switch d { - case DirOutput: - p.Output = a - case DirForward: - p.Forward = a - default: - p.Input = a +// GetDefaultPolicy returns the default filter policy for each direction. +func (f *UFW) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + return f.readPolicy() +} + +// policyValue renders an action as ufw's quoted policy token +// (DEFAULT_*_POLICY="ACCEPT"), matching how ufw itself writes the file. +func (f *UFW) policyValue(a Action) string { + switch a { + case Accept: + return `"ACCEPT"` + case Drop: + return `"DROP"` + case Reject: + return `"REJECT"` } + return "" } // writePolicy writes the default policy for each direction into @@ -1505,36 +1492,6 @@ func (f *UFW) writePolicy(policy *DefaultPolicy) error { return writeConfigFile(UFWDefaults, []byte(strings.Join(lines, "\n")), 0640) } -// get returns the action for a direction on a DefaultPolicy. -func (p *DefaultPolicy) get(d Direction) Action { - switch d { - case DirOutput: - return p.Output - case DirForward: - return p.Forward - } - return p.Input -} - -// policyValue renders an action as ufw's quoted policy token -// (DEFAULT_*_POLICY="ACCEPT"), matching how ufw itself writes the file. -func (f *UFW) policyValue(a Action) string { - switch a { - case Accept: - return `"ACCEPT"` - case Drop: - return `"DROP"` - case Reject: - return `"REJECT"` - } - return "" -} - -// GetDefaultPolicy returns the default filter policy for each direction. -func (f *UFW) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { - return f.readPolicy() -} - // SetDefaultPolicy sets the default filter policy for each direction. func (f *UFW) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { if policy == nil { @@ -1586,3 +1543,44 @@ func (f *UFW) AddAddressSetEntry(ctx context.Context, name, entry string) error func (f *UFW) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { return f.setHelper().RemoveAddressSetEntry(ctx, name, entry) } + +// Reload re-applies edits to the iptables rules files; rules added through the +// ufw CLI apply immediately, but edits to those files only take effect after a +// reload. +func (f *UFW) Reload(ctx context.Context) error { + if f.iptablesRulesChanged { + if _, err := runCommand(ctx, "ufw", "reload"); err != nil { + return err + } + f.iptablesRulesChanged = false + } + return nil +} + +// Close releases any resources held by the backend. +func (f *UFW) Close(ctx context.Context) error { + return nil +} + +// get returns the action for a direction on a DefaultPolicy. +func (p *DefaultPolicy) get(d Direction) Action { + switch d { + case DirOutput: + return p.Output + case DirForward: + return p.Forward + } + return p.Input +} + +// set assigns the action for a direction on a DefaultPolicy. +func (p *DefaultPolicy) set(d Direction, a Action) { + switch d { + case DirOutput: + p.Output = a + case DirForward: + p.Forward = a + default: + p.Input = a + } +} diff --git a/wf_windows.go b/wf_windows.go index 3331e19..041031b 100644 --- a/wf_windows.go +++ b/wf_windows.go @@ -59,6 +59,26 @@ func (f *WF) Type() string { return WFType } +// Capabilities returns the set of features the Windows firewall backend can express. +func (f *WF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + ICMPv6: true, + PortList: true, + ConnState: false, + InterfaceMatch: false, + Logging: false, + RateLimit: false, + ConnLimit: false, + NAT: false, + RuleOrdering: false, + DefaultPolicy: false, + RuleCounters: false, + AddressSets: false, + Comments: true, + } +} + // GetZone reports no zone; Windows Firewall is profile-based, so an interface maps to no single zone. func (f *WF) GetZone(ctx context.Context, iface string) (zoneName string, err error) { if err := ctx.Err(); err != nil { @@ -371,6 +391,29 @@ func (f *WF) hasPrefix(fr wapi.FWRule) bool { return f.rulePrefix != "" && strings.HasPrefix(fr.Name, f.rulePrefix+" ") } +// profileFilter maps a zone name to the single Windows profile bit that GetRules +// and RemoveRule filter on, so both scope to the same rules. ok is false when the +// zone names no specific profile, meaning every profile is in scope (matching an +func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) { + switch { + case strings.EqualFold(zoneName, "public"): + return wapi.NET_FW_PROFILE2_PUBLIC, true + case strings.EqualFold(zoneName, "private"): + return wapi.NET_FW_PROFILE2_PRIVATE, true + case strings.EqualFold(zoneName, "domain"): + return wapi.NET_FW_PROFILE2_DOMAIN, true + } + return 0, false +} + +// profileMatches reports whether a rule's Profiles bitmask is in scope for a +func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool { + if !useFilter { + return true + } + return rulesProfiles == filterProfile +} + // GetRules returns the existing filter rules from the zone. func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { if err := ctx.Err(); err != nil { @@ -415,39 +458,6 @@ func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err return rules, nil } -// profileFilter maps a zone name to the single Windows profile bit that GetRules -// and RemoveRule filter on, so both scope to the same rules. ok is false when the -// zone names no specific profile, meaning every profile is in scope (matching an -// AddRule that stored the rule under the all-profiles default). A rule matches only -// when its Profiles exactly equals this single bit: AddRule always stores a named -// zone's rule under exactly one profile bit or the all-profiles default (never a -// combination), so an exact-equality test is what keeps a specific zone's rules -// disjoint from another zone's and from an all-profiles rule. Testing overlap -// instead (fr.Profiles&profile != 0) would let a single-zone query and, worse, a -// single-zone RemoveRule/Sync match and delete an all-profiles rule — silently -// affecting every other zone too. -func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) { - switch { - case strings.EqualFold(zoneName, "public"): - return wapi.NET_FW_PROFILE2_PUBLIC, true - case strings.EqualFold(zoneName, "private"): - return wapi.NET_FW_PROFILE2_PRIVATE, true - case strings.EqualFold(zoneName, "domain"): - return wapi.NET_FW_PROFILE2_DOMAIN, true - } - return 0, false -} - -// profileMatches reports whether a rule's Profiles bitmask is in scope for a -// profileFilter result: every rule matches when useFilter is false, otherwise -// only a rule scoped to exactly filterProfile (see profileFilter on why exact). -func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool { - if !useFilter { - return true - } - return rulesProfiles == filterProfile -} - // MarshallFWRule encodes a Rule as a Windows FWRule for the given zone. func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) { // The Windows Firewall rule model has only inbound and outbound directions; @@ -645,7 +655,14 @@ func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) { return fwRule, nil } -// AddRule adds a rule to the zone. +// AddRule that stored the rule under the all-profiles default). A rule matches only +// when its Profiles exactly equals this single bit: AddRule always stores a named +// zone's rule under exactly one profile bit or the all-profiles default (never a +// combination), so an exact-equality test is what keeps a specific zone's rules +// disjoint from another zone's and from an all-profiles rule. Testing overlap +// instead (fr.Profiles&profile != 0) would let a single-zone query and, worse, a +// single-zone RemoveRule/Sync match and delete an all-profiles rule — silently +// affecting every other zone too. func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error { if err := ctx.Err(); err != nil { return err @@ -785,34 +802,54 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { return nil } -// Reload is a no-op; Windows Firewall applies rule changes immediately. -func (f *WF) Reload(ctx context.Context) error { - return nil +// GetNATRules is unsupported; WFP is a stateful packet filter only and NAT on +// Windows is handled out of band (netsh portproxy or RRAS). +func (f *WF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) { + return nil, unsupportedNAT(f.Type()) } -// Close releases any resources held by the backend; the Windows firewall holds none. -func (f *WF) Close(ctx context.Context) error { - return nil +// AddNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules). +func (f *WF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + return unsupportedNAT(f.Type()) } -// Capabilities returns the set of features the Windows firewall backend can express. -func (f *WF) Capabilities() Capabilities { - return Capabilities{ - Output: true, - ICMPv6: true, - PortList: true, - ConnState: false, - InterfaceMatch: false, - Logging: false, - RateLimit: false, - ConnLimit: false, - NAT: false, - RuleOrdering: false, - DefaultPolicy: false, - RuleCounters: false, - AddressSets: false, - Comments: true, +// InsertNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules). +func (f *WF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + return unsupportedNAT(f.Type()) +} + +// RemoveNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules). +func (f *WF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + return unsupportedNAT(f.Type()) +} + +// Backup captures the current filter rules managed by this backend. +func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) { + rules, err := f.GetRules(ctx, zoneName) + if err != nil { + return nil, err } + // Backup captures the full filter rule state; Restore reconciles the live rules + // to this set, so every rule read is preserved. + return &Backup{Rules: rules}, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *WF) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + + // Reconcile the live rule set to the backup with a minimal add/remove diff + // rather than removing every rule and re-adding it. Removing all rules first + // leaves a window with no matching filter, and WFP drops in-flight connections + // that no longer match one — including a foreign inbound-allow rule the backup + // itself captured (e.g. the rule keeping this host reachable over SSH while a + // remote restore runs). Sync leaves a rule present in both the firewall and the + // backup untouched, so such a rule is never briefly removed. WFP has no NAT, so + // backup.NATRules is not applied here. + _, _, err := Sync(ctx, f, zoneName, backup.Rules) + return err } // GetDefaultPolicy is unsupported; the Windows firewall exposes no default policy in this model. @@ -855,52 +892,12 @@ func (f *WF) RemoveAddressSetEntry(ctx context.Context, name, entry string) erro return unsupportedSet(f.Type()) } -// Backup captures the current filter rules managed by this backend. -func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) { - rules, err := f.GetRules(ctx, zoneName) - if err != nil { - return nil, err - } - // Backup captures the full filter rule state; Restore reconciles the live rules - // to this set, so every rule read is preserved. - return &Backup{Rules: rules}, nil +// Reload is a no-op; Windows Firewall applies rule changes immediately. +func (f *WF) Reload(ctx context.Context) error { + return nil } -// Restore replaces the managed rules with the contents of a Backup. -func (f *WF) Restore(ctx context.Context, zoneName string, backup *Backup) error { - if backup == nil { - return fmt.Errorf("backup cannot be nil") - } - - // Reconcile the live rule set to the backup with a minimal add/remove diff - // rather than removing every rule and re-adding it. Removing all rules first - // leaves a window with no matching filter, and WFP drops in-flight connections - // that no longer match one — including a foreign inbound-allow rule the backup - // itself captured (e.g. the rule keeping this host reachable over SSH while a - // remote restore runs). Sync leaves a rule present in both the firewall and the - // backup untouched, so such a rule is never briefly removed. WFP has no NAT, so - // backup.NATRules is not applied here. - _, _, err := Sync(ctx, f, zoneName, backup.Rules) - return err -} - -// GetNATRules is unsupported; WFP is a stateful packet filter only and NAT on -// Windows is handled out of band (netsh portproxy or RRAS). -func (f *WF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) { - return nil, unsupportedNAT(f.Type()) -} - -// AddNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules). -func (f *WF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { - return unsupportedNAT(f.Type()) -} - -// InsertNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules). -func (f *WF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { - return unsupportedNAT(f.Type()) -} - -// RemoveNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules). -func (f *WF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { - return unsupportedNAT(f.Type()) +// Close releases any resources held by the backend; the Windows firewall holds none. +func (f *WF) Close(ctx context.Context) error { + return nil }