From a036c8e6e9b17c00c842cd7451b13b31c8eb8536 Mon Sep 17 00:00:00 2001 From: James Coleman Date: Thu, 9 Jul 2026 17:52:19 -0500 Subject: [PATCH] Add TCPUDP protocol, coverage relation, and drop read-side merging Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged value spanning both transports, distinct from ProtocolAny (which matches every IP protocol and carries no port). Backends whose native syntax holds both transports in one row (nftables, ufw, apf) store and read it as one rule; the rest fan it out with expandProtocols. Removing one transport of a merged row splits it via splitMergedRow, which composes the family and protocol splits so an nftables row merged on both axes leaves a correct, non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT. Remove read-side merging. GetRules now reports the firewall's actual rows and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up separately-stored ones, so mergeFamilies, mergeDirections and their helpers are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the NATRule pair) expand a rule across family, transport and direction and decide containment cell by cell, which is what lets Sync stay a no-op against its own output whichever representation a backend chose. Extract the systemd/SysV service helpers out of the iptables backend into services.go so every Linux backend shares one implementation, and document the multi-state rule model and the coverage helpers in the README. --- README.md | 91 +++- apf_linux.go | 367 ++++++------- apf_linux_test.go | 208 ++++++-- backup.go | 14 +- capabilities_linux_test.go | 118 ----- csf_linux.go | 375 ++++++-------- csf_linux_test.go | 191 +++---- firewall.go | 724 ++++++++++++++------------ firewall_test.go | 744 ++++++++++++++++++-------- firewalld_linux.go | 249 +++++---- firewalld_linux_test.go | 99 ++-- hooks_linux.go | 36 +- hooks_linux_test.go | 74 ++- integration_linux_test.go | 15 + integration_nohook_test.go | 11 + integration_test.go | 302 ++++++++--- iptables_linux.go | 1002 ++++++++++++++++++------------------ iptables_linux_test.go | 238 ++++++--- nft_linux.go | 335 ++++++------ nft_linux_test.go | 157 ++++-- pf.go | 334 ++++++------ pf_test.go | 170 ++++-- services.go | 266 ++++++++++ ufw_linux.go | 476 +++++++++++------ ufw_linux_test.go | 155 +++++- wf_windows.go | 103 ++-- wf_windows_test.go | 74 ++- 27 files changed, 4238 insertions(+), 2690 deletions(-) delete mode 100644 capabilities_linux_test.go create mode 100644 integration_nohook_test.go create mode 100644 services.go diff --git a/README.md b/README.md index ec98cac..546a509 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,9 @@ reference. | `Family` | `FamilyAny`, `IPv4`, or `IPv6`. | | `Source` | Source address/CIDR. Prefix with `!` to negate, where supported. | | `Destination` | Destination address/CIDR. Prefix with `!` to negate, where supported. | -| `Port` | Single destination port. A non-zero port requires a concrete `TCP`/`UDP` proto. | +| `Port` | Single destination port. A non-zero port requires a port-carrying proto (`TCP`, `UDP`, `TCPUDP`, `SCTP`). | | `Ports` | Destination port list/ranges (`[]PortRange`). Overrides `Port` when non-empty. | -| `Proto` | `ProtocolAny`, `TCP`, `UDP`, `ICMP`, `ICMPv6`, `SCTP`, `GRE`, `ESP`, or `AH`. | +| `Proto` | `ProtocolAny`, `TCP`, `UDP`, `TCPUDP`, `ICMP`, `ICMPv6`, `SCTP`, `GRE`, `ESP`, or `AH`. `TCPUDP` matches both transports; `ProtocolAny` matches *every* IP protocol and cannot carry a port. | | `ICMPType` | Optional single ICMP type for an `ICMP`/`ICMPv6` rule (`*uint8`, nil = any type). | | `State` | Connection-tracking states to match, OR-combined (e.g. `StateEstablished\|StateRelated`). | | `InInterface` | Inbound interface to match. Empty means any interface. A forward rule may match this alongside `OutInterface`. | @@ -158,12 +158,35 @@ reference. | `Comment` | Optional human-readable label carried where the backend can store one. Informational: not part of rule identity, ignored where unsupported. See `Capabilities().Comments`. | | `HasPrefix` | Informational flag reporting whether the rule carries the configured prefix. | -A `FamilyAny` rule that resolves to an identical IPv4 and IPv6 pair is collapsed -into a single rule when reading rules back. `Capabilities().Output` reports whether -a backend distinguishes input from output (firewalld, for example, does not), and -`Capabilities().Forward` reports whether it can express a forward-chain (routing) -rule. A `DirForward` rule on a backend without forward support is rejected with -`ErrUnsupportedForward`. +`Capabilities().Output` reports whether a backend distinguishes input from output +(firewalld, for example, does not), and `Capabilities().Forward` reports whether it +can express a forward-chain (routing) rule. A `DirForward` rule on a backend without +forward support is rejected with `ErrUnsupportedForward`. + +### Multi-state rules and what `GetRules` reports + +`FamilyAny`, `TCPUDP` and `DirAny` each describe a rule spanning two values of one +axis. Whether such a rule becomes **one** object in the firewall or **several** +depends entirely on the backend's own model: + +- **On add**, a rule is split only on the axes the backend cannot express. nftables + stores a `FamilyAny` rule as one unpinned `inet` row and a `TCPUDP` rule as one + `meta l4proto { tcp, udp }` row; iptables must write a line per family, per + transport and per chain, so the same rule becomes eight lines. +- **On read**, `GetRules` reports the firewall's actual rows. It reports `FamilyAny`, + `TCPUDP` or `DirAny` only for an entry that genuinely carries both values; it never + fabricates one by pairing up separately-stored rows. So the same `FamilyAny` + + `TCPUDP` + `DirAny` rule reads back as one rule from nftables and as eight from + iptables. +- **On remove**, a target clears every row it covers. If a stored row covers *more* + than the target — removing IPv4 from a family-agnostic nftables row — the backend + deletes that row and re-adds the remainder in its place, so the untargeted coverage + survives. Where its model cannot express the remainder, it returns `ErrUnsupported` + rather than over-removing. + +Since the read-back shape is backend-specific, compare rules by **coverage**, not +equality — see below. `Sync` does exactly this, which is why it stays a no-op against +its own output whichever representation the backend chose. ### `DirAny` — both directions @@ -176,12 +199,12 @@ So `DirAny` with `Source: X` matches inbound traffic *from* `X` and outbound traffic *to* `X`. - **On add**, a `DirAny` rule fans out into a concrete input row plus its swapped - output row. **On read**, an input rule and its swapped output twin are collapsed - back into one `DirAny` rule (the same way a v4/v6 pair collapses to `FamilyAny`). + output row, except on csf/apf, whose bare-host `csf.allow`/`allow_hosts` line is + inherently bidirectional and stores it as one entry (read back as `DirAny`). - **Removing a single direction** of a `DirAny` rule leaves the other in place: the chain backends drop only that direction's row, while csf/apf split their - bidirectional plain `csf.allow`/`allow_hosts` line and re-express the surviving - direction through their raw-iptables hook. + bidirectional plain line and re-express the surviving direction through their + raw-iptables hook. - On csf/apf, a bare (address-only, no-port) `DirAny` host allow is the single bidirectional plain line; a one-way (`DirInput`/`DirOutput`) bare host allow is written to the hook instead, since a plain line is inherently bidirectional and an @@ -191,6 +214,50 @@ traffic *to* `X`. `DirAny` rule degrades to its input half (`DirInput`, same fields) rather than being rejected. +### `TCPUDP` — both transports + +`TCPUDP` is the protocol analog of `FamilyAny` and `DirAny`: it matches TCP **and** +UDP. It is **not** `ProtocolAny`, which matches *every* IP protocol (ICMP, GRE, ESP, +…) and therefore cannot carry a port — a `ProtocolAny` rule with a port is rejected +by every backend. + +- **On add**, a `TCPUDP` rule fans out into a tcp row plus a udp row on the backends + with no both-transports form (iptables, pf, firewalld, wf, csf), each of which then + reads back as its own rule. +- Three backends express it natively in a single rule and need no fan-out: + **nftables** (`meta l4proto { tcp, udp }` with a `th dport` match), **ufw** (its + bare-port `any`-protocol tuple) and **apf** (a protocol-less advanced trust line, + which apf itself applies to tcp and udp). Those read back as one `TCPUDP` rule. +- **Removing a single transport** of a `TCPUDP` rule leaves the other in place: the + fan-out backends drop only that transport's row, while nftables and ufw split their + single both-transports row and re-add the surviving transport. + +### Coverage: `Covers` and `CoveredBy` + +Because a rule may be stored as one row or several, a caller checking whether a rule +is already installed cannot compare with `==`. Two helpers express the coverage +relation directly: + +```go +// Does this one rule contain that one? +want := &fw.Rule{Family: fw.FamilyAny, Proto: fw.TCPUDP, Direction: fw.DirAny, Port: 53, Action: fw.Accept} +want.Covers(&fw.Rule{Family: fw.IPv4, Proto: fw.UDP, Direction: fw.DirInput, Port: 53, Action: fw.Accept}) // true + +// Is this rule fully present across a set — even if no single rule contains it? +existing, _ := mgr.GetRules(ctx, "") +if !want.CoveredBy(existing) { + _ = mgr.AddRule(ctx, "", want) +} +``` + +`Covers` is asymmetric: a `TCPUDP` rule covers its TCP half, never the reverse. +`CoveredBy` is its set-valued inverse — it expands the receiver across all three +axes and requires every resulting cell to be covered by *some* rule in the set. That +is what makes it work against a fan-out backend, where no single stored row covers the +rule but the rows together do; and it is why a rule spanning both transports is not +reported present when only its TCP half is. `NATRule.Covers` and `NATRule.CoveredBy` +mirror them over family, the only axis a NAT rule spans. + ## NAT (port forwarding and masquerade) NAT rules are managed separately from filter rules through diff --git a/apf_linux.go b/apf_linux.go index c688254..760f16c 100644 --- a/apf_linux.go +++ b/apf_linux.go @@ -8,12 +8,9 @@ import ( "os" "strconv" "strings" - - dbus "github.com/coreos/go-systemd/dbus" ) const ( - APFType = "apf" APFConf = "/etc/apf/conf.apf" APFAllow = "/etc/apf/allow_hosts.rules" APFDeny = "/etc/apf/deny_hosts.rules" @@ -34,17 +31,19 @@ const ( // APF manages the firewall through apf's config files and a managed pre-hook. type APF struct { ConfigChanged bool - // rulePrefix is prepended to a rule's comment when it is written as a - // full-line comment above a rule in allow_hosts.rules/deny_hosts.rules, so - // rules this library creates can be told apart. conf.apf port/icmp-list - // rules carry no per-rule comment and so ignore it. + // rulePrefix tags rules this library creates so they can be told apart + // from foreign rules. In allow_hosts.rules/deny_hosts.rules it is + // prepended to the comment written on the line above each rule; + // conf.apf port/icmp-list rules carry no per-rule comment and so + // cannot carry the tag. rulePrefix string - // ipv6Enabled mirrors conf.apf's USE_IPV6. apf's own shell logic silently - // no-ops a bare IPv6 host in allow_hosts.rules/deny_hosts.rules - // (apf_trust.sh trust_hosts()) and the native IG_ICMPV6_TYPES/EG_ICMPV6_TYPES - // lists (cports.common) whenever USE_IPV6 is not "1" (the shipped default), - // so AddRule must reject those shapes rather than write a rule apf will - // never enforce. + // ipv6Enabled mirrors conf.apf's USE_IPV6. With it off (the shipped default) apf + // enforces no IPv6 at all: its shell logic no-ops a bare IPv6 host in + // allow_hosts.rules/deny_hosts.rules (apf_trust.sh trust_hosts()) and the native + // IG_ICMPV6_TYPES/EG_ICMPV6_TYPES lists (cports.common), and ip6tables is never + // flushed on (re)load (apf_ipt.sh ipt6()). AddRule therefore rejects every + // concrete-IPv6 rule (see ipv6Unavailable) rather than write one apf will never + // enforce. ipv6Enabled bool } @@ -53,49 +52,10 @@ func NewAPF(ctx context.Context, rulePrefix string) (*APF, error) { apf := new(APF) apf.rulePrefix = rulePrefix - // Connect to systemd dbus interface. - var prop *dbus.Property - conn, err := dbus.NewWithContext(ctx) - if err == nil { - defer conn.Close() - // Find the systemd service for apf and confirm it was loaded. - prop, _ = conn.GetUnitPropertyContext(ctx, "apf.service", "ActiveState") - } - - // If the service is not active in SystemD, check SysV status. - if prop == nil || prop.Value.Value() != "active" { - // Run SysV check to see if APF is enabled there. - results, err := runCommand(ctx, "chkconfig", "--list", "apf") - if err != nil { - return nil, fmt.Errorf("the apf service is not active on this server") - } - - // Parse the result to see if apf is on. - foundOn := false - for _, line := range results { - fields := strings.Fields(line) - if len(fields) == 0 { - continue - } - - // If this is not the apf line, skip. - if fields[0] != "apf" { - continue - } - - // Parse the runlevel:status output to check if any are on. - for _, f := range fields { - _, status, found := strings.Cut(f, ":") - if found && status == "on" { - foundOn = true - } - } - } - - // If APF is not on, return result. - if !foundOn { - return nil, fmt.Errorf("the apf service is not active or enabled on this server") - } + // Confirm apf is enabled under whatever init system the host uses + // (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local). + if !serviceEnabled(ctx, "apf") { + return nil, fmt.Errorf("the apf service is not active or enabled on this server") } // Confirm config files exist. @@ -127,12 +87,11 @@ func (f *APF) Capabilities() Capabilities { return Capabilities{ Output: true, Forward: true, - // ICMPv6 mirrors ipv6Enabled: apf's native IG_ICMPV6_TYPES/EG_ICMPV6_TYPES - // lists (what a plain, qualifier-free ICMPv6 rule uses) are a silent no-op - // on the real firewall whenever conf.apf's USE_IPV6 is not "1" (confirmed + // ICMPv6 mirrors ipv6Enabled: with conf.apf's USE_IPV6 off, apf never touches + // ip6tables, so neither its native config nor the raw-iptables hook yields a + // rule apf will keep in sync across a reload (see ipv6Unavailable). Confirmed // against a real apf install: ip6tables carries no rule for a type in - // IG_ICMPV6_TYPES when USE_IPV6=0). A rule that also needs state/interface/ - // log/rate matching still works via the raw-iptables hook regardless. + // IG_ICMPV6_TYPES when USE_IPV6=0. ICMPv6: f.ipv6Enabled, PortList: false, ConnState: true, @@ -335,6 +294,18 @@ func (f *APF) stopKey(proto Protocol) string { // denyActionFor reads conf.apf's setting for the given protocol (see func (f *APF) denyActionFor(proto Protocol) Action { + // A TCPUDP deny is one protocol-less advanced line, which apf applies with + // TCP_STOP on the tcp rule it derives and UDP_STOP on the udp one. It therefore + // has a single native action only when the two settings agree; when they differ + // the rule cannot be one Rule with one Action, so report ActionInvalid and let + // addRule route it to the hook, whose lines carry the action verbatim. + if proto == TCPUDP { + tcp := f.readStopAction(APFConf, "TCP_STOP") + if f.readStopAction(APFConf, "UDP_STOP") != tcp { + return ActionInvalid + } + return tcp + } return f.readStopAction(APFConf, f.stopKey(proto)) } @@ -350,8 +321,16 @@ func (f *APF) resolveAction(base Action, proto Protocol) Action { return base } switch proto { - case TCP, UDP: - return f.denyActionFor(proto) + case TCP, UDP, TCPUDP: + // denyActionFor reports ActionInvalid for a TCPUDP rule whose TCP_STOP and + // UDP_STOP disagree — the signal addRule uses to route such a rule to the hook, + // since no single native line carries two actions. A rule being read back still + // needs a usable action, so fall back to the file's own (a foreign + // protocol-less deny line written outside this library can hit this). + if a := f.denyActionFor(proto); a != ActionInvalid { + return a + } + return base default: return base } @@ -428,6 +407,14 @@ func (f *APF) ParseAdvRule(val string, action Action) (r *Rule) { } } + // An advanced line with no protocol field covers both transports: apf's trust + // parser derives a `-p tcp` rule and a `-p udp` rule from it. Report that as + // TCPUDP, not ProtocolAny, so the rule reads back as the one that was written and + // is not mistaken for an every-protocol match. + if r.Proto == ProtocolAny { + r.Proto = TCPUDP + } + // The action depends on the protocol just parsed (see resolveAction), so // it is resolved last rather than stamped up front. r.Action = f.resolveAction(action, r.Proto) @@ -661,12 +648,12 @@ func (f *APF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err } rules = append(rules, hookRules...) - // Merge rules across families so a v4/v6 hook pair collapses to one rule, then - // collapse each input/output twin into one DirAny rule — a plain allow_hosts/ - // deny_hosts IP, read as an inbound (source) rule plus an outbound (destination) - // rule, reads back as the single bidirectional line it was written as. - rules = mergeFamilies(rules) - rules = mergeDirections(rules) + // Every entry above is reported as apf stores it, and several apf entries cover + // more than one axis on their own: a CPORTS or CLIMIT entry is dual-stack and + // decodes to FamilyAny, a protocol-less advanced line decodes to TCPUDP, and a + // bare allow_hosts/deny_hosts IP is one bidirectional line that decodes to DirAny. + // What apf keys separately — the TCP and UDP CPORTS lists, the IG_ and EG_ prefixes, + // the per-family hook lines — stays several rules. return } @@ -760,25 +747,28 @@ func (f *APF) EditRulePort(orig, key, val string, r *Rule, remove bool) string { // Determine which config list this key manages and the tokens the rule // contributes to it. Non-matching keys are returned untouched. + // A CPORTS list is per-transport, so a TCPUDP rule contributes its ports to both + // the TCP and the UDP list, and reads back as one rule per list. coversProtocol + // gates each list: TCPUDP covers either, a concrete transport only its own. var wantTokens []string switch key { case "IG_TCP_CPORTS": - if r.IsOutput() || r.Proto != TCP { + if r.IsOutput() || !coversProtocol(r.Proto, TCP) { return orig } wantTokens = f.portTokens(r) case "IG_UDP_CPORTS": - if r.IsOutput() || r.Proto != UDP { + if r.IsOutput() || !coversProtocol(r.Proto, UDP) { return orig } wantTokens = f.portTokens(r) case "EG_TCP_CPORTS": - if !r.IsOutput() || r.Proto != TCP { + if !r.IsOutput() || !coversProtocol(r.Proto, TCP) { return orig } wantTokens = f.portTokens(r) case "EG_UDP_CPORTS": - if !r.IsOutput() || r.Proto != UDP { + if !r.IsOutput() || !coversProtocol(r.Proto, UDP) { return orig } wantTokens = f.portTokens(r) @@ -992,12 +982,21 @@ func (f *APF) MarshalAdvRule(r *Rule) (string, error) { return "", fmt.Errorf("apf advanced rules cannot match both a source and destination port") } + // apf's advanced rule carries an optional protocol field, and treats a line + // without one as both transports: its trust parser emits a `-p tcp` rule and a + // `-p udp` rule for it. So TCPUDP is written by omitting the field, and no other + // protocol may be: ProtocolAny would mean every IP protocol, which the omitted + // field does not express. var parts []string switch r.Proto { case TCP: parts = append(parts, "tcp") case UDP: parts = append(parts, "udp") + case TCPUDP: + // No protocol field: apf reads that as tcp plus udp. + default: + return "", fmt.Errorf("an apf advanced rule carries tcp, udp or both: %w", ErrUnsupported) } if r.IsOutput() { parts = append(parts, "out") @@ -1201,12 +1200,11 @@ func (f *APF) EditIPList(ctx context.Context, filePath string, action Action, r // uses the "any" network as a placeholder. That literal is family- // specific (0.0.0.0/0 vs ::/0) and read back with its family, so emit // the placeholder matching the rule's family. A family-neutral rule has - // no single literal, so cover both families — mergeFamilies collapses - // the v4/v6 pair back to FamilyAny on read, keeping the rule both - // readable and removable rather than silently becoming IPv4-only. + // no single literal, so emit a line per family rather than let the rule + // silently become IPv4-only; each line reads back as its own rule. writeAny := func(placeholder string) { var tokens []string - if r.Proto != ProtocolAny { + if r.Proto != TCPUDP { tokens = append(tokens, r.Proto.String()) } if r.IsOutput() { @@ -1269,30 +1267,14 @@ func (f *APF) nativeICMPv6(r *Rule) bool { !r.Log && r.RateLimit == nil && f.isConfRule(r) } -// ipv6Unavailable reports whether adding r would silently write something -// apf itself never enforces: a bare IPv6 host in allow_hosts.rules/ -// deny_hosts.rules, or a native ICMPv6 type, are both no-op'd by apf's own -// shell logic when conf.apf's USE_IPV6 is not "1". A rule diverted to the -// raw-iptables hook is unaffected — the hook runs ip6tables directly, outside -// apf's USE_IPV6-gated logic — so this only applies to the two native paths. -func (f *APF) ipv6Unavailable(r *Rule) bool { - if f.ipv6Enabled { - return false - } - if f.nativeICMPv6(r) { - return true - } - return r.impliedFamily() == IPv6 && (r.Source != "" || r.Destination != "") -} - // barePortAccept reports whether a rule is an address-less tcp/udp port accept — // the shape apf stores either in a dual-stack conf.apf CPORTS list (a FamilyAny // port) or, per family, through the raw-iptables hook (a single-family port, or a -// FamilyAny that GetRules merged back from a v4+v6 hook pair). Both the add-time hook +// FamilyAny added as a v4 hook rule plus a v6 hook rule). Both the add-time hook // decision (dualStackPortNeedsHook) and the remove-time split/clear // (removeDualStackPort) build on it. func (f *APF) barePortAccept(r *Rule) bool { - return (r.Proto == TCP || r.Proto == UDP) && r.HasPorts() && + return onProtocolAxis(r.Proto) && r.HasPorts() && r.Source == "" && r.Destination == "" && r.Action == Accept } @@ -1336,14 +1318,14 @@ func (f *APF) needsHook(r *Rule) bool { // single port or one underscore range, and the only native multi-port shape is an // address-less accept (isConfRule, carried by the IG_*_CPORTS comma lists); // every other list goes to the hook's iptables multiport match. - if (r.Proto == TCP || r.Proto == UDP) && + if onProtocolAxis(r.Proto) && (len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1) && !f.isConfRule(r) { return true } // A source-port match with no address has no advanced-rule form (advanced rules // require an address); iptables matches --sport directly, so it goes to the hook. if r.HasSourcePorts() && r.Source == "" && r.Destination == "" && - (r.Proto == TCP || r.Proto == UDP) { + onProtocolAxis(r.Proto) { return true } // A connection limit conf.apf's IG_*_CLIMIT cannot express — anything but a @@ -1378,6 +1360,15 @@ func (f *APF) needsHook(r *Rule) bool { // prior state, including inert entries the gate would reject as fresh no-op // writes. func (f *APF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6Gate bool) error { + // Reject a concrete-IPv6 rule when apf's own IPv6 handling is off, ahead of every + // routing decision below: neither conf.apf nor the pre-hook can carry one that apf + // will keep in sync (see ipv6Unavailable). Checking here rather than past the hook + // branch also keeps a DirAny rule from writing its input half before its output + // half is rejected. + if enforceIPv6Gate && ipv6Unavailable(f.ipv6Enabled, r) { + return fmt.Errorf("apf's IPv6 handling is disabled (conf.apf USE_IPV6 is not \"1\"): %w", ErrUnsupported) + } + // A DirAny rule maps to a single native construct only as a bare-host plain line; // every other DirAny shape fans out into a concrete input rule plus its swapped // output rule, each routed independently (a half may itself need the hook). @@ -1401,9 +1392,6 @@ func (f *APF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6 f.ConfigChanged = f.ConfigChanged || changed return err } - if enforceIPv6Gate && f.ipv6Unavailable(r) { - return fmt.Errorf("apf's IPv6 handling is disabled (conf.apf USE_IPV6 is not \"1\"): %w", ErrUnsupported) - } if err := iptablesRuleValid(r); err != nil { return fmt.Errorf("%v: %w", err, ErrUnsupported) } @@ -1517,25 +1505,44 @@ func (f *APF) cPortsKey(proto Protocol, output bool) string { return "" } +// portInCPorts reports whether a bare port accept is stored in conf.apf's CPORTS +// lists rather than as its own hook rule. A TCPUDP rule contributes to both the TCP +// and the UDP list, so it is CPORTS-backed only when every transport it covers is +// present — if one list is missing the port, that transport lives in the hook and +// the whole rule must be treated as hook-backed. cPortsKey is only ever asked about +// a concrete transport, since expandProtocols has split the rule first. +func (f *APF) portInCPorts(r *Rule) (bool, error) { + for _, sub := range expandProtocols(r) { + val, err := readConfValue(APFConf, f.cPortsKey(sub.Proto, sub.IsOutput())) + if err != nil { + return false, err + } + found := false + for _, e := range f.ParsePorts(val, sub.Proto, sub.IsOutput()) { + if e.EqualForRemoval(sub, true) { + found = true + break + } + } + if !found { + return false, nil + } + } + return true, nil +} + // removeDualStackPort removes a single-family bare tcp/udp port accept. Such a rule // is stored either as its own per-family hook rule or as one family of a dual-stack // conf.apf CPORTS entry (a FamilyAny port). Read the CPORTS list the port would live -// in — not the merged rule view, which cannot tell a genuine CPORTS entry from a -// pair of single-family hook rules — and split it when present: drop the dual-stack +// in — the rule view alone cannot tell a genuine CPORTS entry from a pair of +// single-family hook rules — and split it when present: drop the dual-stack // entry and re-add the surviving opposite family as a hook rule, so the untargeted // family keeps its coverage. Otherwise the rule is a per-family hook rule. func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error { - val, err := readConfValue(APFConf, f.cPortsKey(r.Proto, r.IsOutput())) + inCPorts, err := f.portInCPorts(r) if err != nil { return err } - inCPorts := false - for _, e := range f.ParsePorts(val, r.Proto, r.IsOutput()) { - if e.EqualForRemoval(r, true) { - inCPorts = true - break - } - } if inCPorts { // Drop the dual-stack CPORTS entry (family-agnostic), then re-add the // surviving opposite family through the hook. @@ -1556,11 +1563,11 @@ func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error { // removeFamilyAnyPort removes a FamilyAny address-less bare tcp/udp port accept. Its // two families live in a dual-stack conf.apf CPORTS entry (a genuine FamilyAny add), -// in a v4+v6 pair in the hook (two separate concrete-family adds that GetRules merged -// back into one FamilyAny rule), or split across both. The merged read cannot tell -// which, so remove the rule from both backings — EditConf drops it from the CPORTS -// list and the hook edit drops both per-family rows, and each no-ops when the rule is -// absent — clearing the whole merged rule wherever it lives. Unlike the single-family +// in a v4+v6 pair in the hook (two separate concrete-family adds), or split across +// both. A read cannot tell which, so remove the rule from both backings — EditConf +// drops it from the CPORTS list and the hook edit drops both per-family rows, and +// each no-ops when the rule is absent — clearing every cell the target covers, +// wherever it lives. Unlike the single-family func (f *APF) removeFamilyAnyPort(ctx context.Context, r *Rule) error { if err := f.EditConf(ctx, r, true); err != nil { return err @@ -1621,8 +1628,8 @@ func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { return f.removeDualStackPort(ctx, r) } // A FamilyAny bare tcp/udp port accept is stored in a dual-stack CPORTS entry, in a - // v4+v6 hook pair (separate concrete-family adds GetRules merged back), or split - // across both; removeFamilyAnyPort clears it from every backing (see there). + // v4+v6 hook pair (separate concrete-family adds), or split across both; + // removeFamilyAnyPort clears it from every backing (see there). if f.barePortAccept(r) { return f.removeFamilyAnyPort(ctx, r) } @@ -1720,8 +1727,7 @@ func (f *APF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, err // added carries the configured prefix in its -m comment tag and UnmarshalNATRule // derives HasPrefix from it (an empty prefix writes no tag, so HasPrefix is // false; a rule hand-added without the tag likewise reports false). - merged := mergeNATFamilies(rules) - return merged, nil + return rules, nil } // natFamilies lists the address families a NAT rule is written for: a rule @@ -1897,6 +1903,67 @@ func (f *APF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) er return f.editNATFile(r, true) } +// GetDefaultPolicy is unsupported: apf has no managed default-policy control. +func (f *APF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + return nil, unsupportedPolicy(f.Type()) +} + +// SetDefaultPolicy is unsupported: apf has no managed default-policy control. +func (f *APF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + return unsupportedPolicy(f.Type()) +} + +// GetAddressSets returns the address sets carried by the apf pre-hook. +func (f *APF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + return f.hook().getAddressSets() +} + +// GetAddressSet returns a single address set by name, or an error if absent. +func (f *APF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + 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 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 { + 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 drops a set's ipset commands from the pre-hook. It fails if a +func (f *APF) RemoveAddressSet(ctx context.Context, name string) error { + changed, err := f.hook().editAddressSet(&AddressSet{Name: name}, true) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// AddAddressSetEntry adds an entry to an existing set in the pre-hook. +func (f *APF) AddAddressSetEntry(ctx context.Context, name, entry string) error { + changed, err := f.hook().editAddressSetEntry(name, entry, false) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// RemoveAddressSetEntry removes an entry from an existing set in the pre-hook. +func (f *APF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + changed, err := f.hook().editAddressSetEntry(name, entry, true) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + // Backup captures the current filter and NAT rules managed by this backend. func (f *APF) Backup(ctx context.Context, zoneName string) (*Backup, error) { rules, err := f.GetRules(ctx, zoneName) @@ -1964,68 +2031,6 @@ func (f *APF) Restore(ctx context.Context, zoneName string, backup *Backup) erro return nil } -// GetDefaultPolicy is unsupported: apf has no managed default-policy control. -func (f *APF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { - return nil, unsupportedPolicy(f.Type()) -} - -// SetDefaultPolicy is unsupported: apf has no managed default-policy control. -func (f *APF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { - return unsupportedPolicy(f.Type()) -} - -// GetAddressSets returns the address sets carried by the apf pre-hook. -func (f *APF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { - return f.hook().getAddressSets() -} - -// GetAddressSet returns a single address set by name, or an error if absent. -func (f *APF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { - 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 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 { - 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 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 { - changed, err := f.hook().editAddressSet(&AddressSet{Name: name}, true) - f.ConfigChanged = f.ConfigChanged || changed - return err -} - -// AddAddressSetEntry adds an entry to an existing set in the pre-hook. -func (f *APF) AddAddressSetEntry(ctx context.Context, name, entry string) error { - changed, err := f.hook().editAddressSetEntry(name, entry, false) - f.ConfigChanged = f.ConfigChanged || changed - return err -} - -// RemoveAddressSetEntry removes an entry from an existing set in the pre-hook. -func (f *APF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { - 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. func (f *APF) Reload(ctx context.Context) error { // apf --restart rewrites and reloads the whole ruleset, which is disruptive, so diff --git a/apf_linux_test.go b/apf_linux_test.go index c571917..5e9fe7c 100644 --- a/apf_linux_test.go +++ b/apf_linux_test.go @@ -125,11 +125,11 @@ func TestAPFDualStackPort(t *testing.T) { } // TestAPFBarePortAccept locks in the removal routing shared by single-family and -// FamilyAny bare tcp/udp port accepts. A FamilyAny port merged back from a v4+v6 hook -// pair reads as impliedFamily FamilyAny, so dualStackPortNeedsHook (single-family -// only) does not match it; barePortAccept must, so RemoveRule routes it to the -// family-agnostic removeFamilyAnyPort instead of the native-only EditConf path that -// cannot clear the hook rows. Regression for mergedfamilyremove leaving the port open. +// FamilyAny bare tcp/udp port accepts. A FamilyAny port target has impliedFamily +// FamilyAny, so dualStackPortNeedsHook (single-family only) does not match it; +// barePortAccept must, so RemoveRule routes it to the family-agnostic +// removeFamilyAnyPort instead of the native-only EditConf path that cannot clear the +// hook rows. Regression for a family-agnostic removal leaving the port open. func TestAPFBarePortAccept(t *testing.T) { fw := new(APF) // Both a concrete-family and a FamilyAny bare port accept are bare-port accepts; @@ -369,40 +369,6 @@ func TestAPFICMPv6AndAllWildcard(t *testing.T) { require.False(t, fw.nativeICMPv6(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept})) } -// apf's own shell logic (apf_trust.sh trust_hosts(), cports.common) silently -// no-ops a bare IPv6 host and the native ICMPv6 type lists whenever conf.apf's -// USE_IPV6 is not "1" (the shipped default). ipv6Unavailable must flag -// exactly those two native shapes, and only when ipv6Enabled is false; a rule -// diverted to the raw-iptables hook (which bypasses USE_IPV6 entirely) must -// never be flagged. -func TestAPFIPv6UnavailableGate(t *testing.T) { - disabled := &APF{ipv6Enabled: false} - enabled := &APF{ipv6Enabled: true} - - nativeICMPv6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept} - require.True(t, disabled.ipv6Unavailable(nativeICMPv6), - "a native ICMPv6 type rule must be blocked when USE_IPV6 is off") - require.False(t, enabled.ipv6Unavailable(nativeICMPv6), - "a native ICMPv6 type rule must be allowed when USE_IPV6 is on") - - bareV6Host := &Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept} - require.True(t, disabled.ipv6Unavailable(bareV6Host), - "a bare IPv6 host rule must be blocked when USE_IPV6 is off") - require.False(t, enabled.ipv6Unavailable(bareV6Host), - "a bare IPv6 host rule must be allowed when USE_IPV6 is on") - - bareV4Host := &Rule{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept} - require.False(t, disabled.ipv6Unavailable(bareV4Host), - "an IPv4 rule must never be blocked by the IPv6 gate") - - // An ICMPv6 rule that also needs state matching is diverted to the - // raw-iptables hook (see nativeICMPv6), which runs outside apf's - // USE_IPV6-gated shell logic, so it must not be blocked either way. - hookRoutedICMPv6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept} - require.False(t, disabled.ipv6Unavailable(hookRoutedICMPv6), - "a hook-routed ICMPv6 rule must not be blocked by the IPv6 gate") -} - func TestAPFIPListComment(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "allow_hosts.rules") @@ -685,7 +651,8 @@ func TestAPFBareProtocolRoutesToHook(t *testing.T) { // field, so it writes an "any" placeholder — previously the IPv4 literal // 0.0.0.0/0 regardless of family, so a family-neutral rule read back as IPv4 // (Sync churn) and an IPv6 rule became IPv4-only. The placeholder now matches the -// rule's family (a family-neutral rule covers both, merging back on read). +// rule's family, and a family-neutral rule writes one line per family: two rows that +// cover the rule between them. // // The rule action is Drop, not Reject: a tcp port-carrying deny_hosts entry is an // apf "advanced" entry, which apf routes through TCP_STOP (not ALL_STOP) — Drop is @@ -704,12 +671,19 @@ func TestAPFPortOnlyRejectFamily(t *testing.T) { require.NoError(t, os.WriteFile(deny, nil, 0o644)) require.NoError(t, fw.EditIPList(ctx, deny, Drop, rule, false)) - // GetRules applies mergeFamilies to the parsed list; mirror it here. + // A concrete-family rule is one line; a family-neutral one is a line per family. + // Either way the rows read back cover exactly the rule that was written. + wantRows := 1 + if rule.impliedFamily() == FamilyAny { + wantRows = 2 + } got, err := fw.ParseIPList(deny, Drop) require.NoError(t, err) - got = mergeFamilies(got) - require.Len(t, got, 1, "port-only deny (%s) must round-trip to one rule", rule.Family) - require.True(t, rule.Equal(got[0], false), "read-back rule must equal the written one; want family=%s got family=%s", rule.Family, got[0].Family) + require.Len(t, got, wantRows, "port-only deny (%s) must round-trip to %d row(s)", rule.Family, wantRows) + require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule; want family=%s", rule.Family) + for _, g := range got { + require.True(t, rule.Covers(g), "read-back row must not widen the written rule: %+v", g) + } // It must also be removable (matched back on delete). require.NoError(t, fw.EditIPList(ctx, deny, Drop, rule, true)) @@ -817,3 +791,149 @@ func TestAPFConnLimitCountChangeReloads(t *testing.T) { require.Equal(t, `IG_TCP_CLIMIT="80:25"`, out) require.False(t, fw.ConfigChanged, "an unchanged connlimit count must not set ConfigChanged") } + +// TestAPFTCPUDPCPortsFanOut covers the write half of the both-transports port accept: +// conf.apf's CPORTS lists are per-transport, so a TCPUDP port must be added to (and +// removed from) both. Regression: EditRulePort keyed on `r.Proto != TCP` / `!= UDP`, +// so a both-transports rule matched neither list and the port was never opened. +func TestAPFTCPUDPCPortsFanOut(t *testing.T) { + fw := new(APF) + r := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirInput} + + require.Equal(t, `IG_TCP_CPORTS="22,80"`, + fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22", r, false), + "a tcpudp port must be added to the tcp list") + require.Equal(t, `IG_UDP_CPORTS="53,80"`, + fw.EditRulePort(`IG_UDP_CPORTS="53"`, "IG_UDP_CPORTS", "53", r, false), + "a tcpudp port must be added to the udp list") + + // Removal clears it from both lists. + require.Equal(t, `IG_TCP_CPORTS="22"`, + fw.EditRulePort(`IG_TCP_CPORTS="22,80"`, "IG_TCP_CPORTS", "22,80", r, true)) + require.Equal(t, `IG_UDP_CPORTS="53"`, + fw.EditRulePort(`IG_UDP_CPORTS="53,80"`, "IG_UDP_CPORTS", "53,80", r, true)) + + // A concrete transport still touches only its own list. + tcp := &Rule{Proto: TCP, Port: 80, Action: Accept, Direction: DirInput} + require.Equal(t, `IG_UDP_CPORTS="53"`, + fw.EditRulePort(`IG_UDP_CPORTS="53"`, "IG_UDP_CPORTS", "53", tcp, false), + "a tcp rule must not open the udp port") + + // An outbound rule touches only the egress lists. + out := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirOutput} + require.Equal(t, `IG_TCP_CPORTS="22"`, + fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22", out, false)) + require.Equal(t, `EG_TCP_CPORTS="22,80"`, + fw.EditRulePort(`EG_TCP_CPORTS="22"`, "EG_TCP_CPORTS", "22", out, false)) +} + +// TestAPFTCPUDPCPortsReadBack covers the read half: apf's CPORTS lists are keyed per +// transport, so a TCPUDP port is one entry in each and reads back as one rule per +// list — two dual-stack rules that together cover the TCPUDP rule that was written. +// A port in only one list covers only its own transport. +func TestAPFTCPUDPCPortsReadBack(t *testing.T) { + fw := new(APF) + rules := append(fw.ParsePorts("80", TCP, false), fw.ParsePorts("80", UDP, false)...) + require.Len(t, rules, 2, "the two lists parse independently") + for _, r := range rules { + require.Equal(t, FamilyAny, r.Family, "a CPORTS entry is dual-stack") + } + + both := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirInput} + require.True(t, both.CoveredBy(rules), "the tcp+udp CPORTS entries cover the TCPUDP rule") + + // A port in only one list leaves the other transport uncovered. + tcpOnly := fw.ParsePorts("80", TCP, false) + require.Len(t, tcpOnly, 1) + require.Equal(t, TCP, tcpOnly[0].Proto) + require.False(t, both.CoveredBy(tcpOnly), "the tcp entry alone must not cover a TCPUDP rule") + require.True(t, (&Rule{Proto: TCP, Port: 80, Action: Accept, Direction: DirInput}).CoveredBy(tcpOnly)) +} + +// TestAPFTCPUDPHookRoundTrip covers the rule shape that routes to the pre-hook (a +// port match that also carries connection state). The hook has no both-transports +// iptables form, so it fans the rule into a tcp line and a udp line, which read back +// as two rules covering the one that was written. One remove clears both. +func TestAPFTCPUDPHookRoundTrip(t *testing.T) { + fw := new(APF) + r := &Rule{Family: IPv4, Proto: TCPUDP, Port: 80, State: StateEstablished, Action: Accept, Direction: DirInput} + require.True(t, fw.needsHook(r), "a stateful port match has no native apf form") + + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(t.TempDir(), "apfpre.sh"), + hookPerm: 0700, + } + changed, err := h.edit(r, false) + require.NoError(t, err) + require.True(t, changed) + + raw, err := h.getRules() + require.NoError(t, err) + require.Len(t, raw, 2, "the hook fans a tcpudp rule into a tcp and a udp line") + + require.True(t, r.CoveredBy(raw), "the fanned pair must cover the rule that was written") + for _, g := range raw { + require.True(t, r.Covers(g), "a fanned line must not widen the rule: %+v", g) + } + + // One remove clears both lines. + changed, err = h.edit(r, true) + require.NoError(t, err) + require.True(t, changed) + raw, err = h.getRules() + require.NoError(t, err) + require.Empty(t, raw) +} + +// TestAPFTCPUDPAdvRule: apf's advanced rule treats a missing protocol field as both +// transports (its trust parser derives a -p tcp and a -p udp rule from it), so TCPUDP +// is written by omitting the field and must read back as TCPUDP — never ProtocolAny, +// which would claim every IP protocol is matched. +func TestAPFTCPUDPAdvRule(t *testing.T) { + fw := new(APF) + r := &Rule{Proto: TCPUDP, Port: 80, Source: "192.0.2.1", Action: Accept, Direction: DirInput} + + line, err := fw.MarshalAdvRule(r) + require.NoError(t, err) + require.Equal(t, "in:d=80:s=192.0.2.1", line, "the protocol field is omitted for both transports") + + back := fw.ParseAdvRule(line, Accept) + require.NotNil(t, back) + require.Equal(t, TCPUDP, back.Proto, "a protocol-less advanced line is tcp+udp, not every protocol") + require.True(t, back.EqualBase(r, true)) + + // A concrete transport names itself and round-trips unchanged. + line, err = fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Source: "192.0.2.1", Action: Accept}) + require.NoError(t, err) + require.Equal(t, "tcp:in:d=80:s=192.0.2.1", line) + require.Equal(t, TCP, fw.ParseAdvRule(line, Accept).Proto) + + // ProtocolAny has no advanced-rule form: the omitted field means tcp+udp, not + // every protocol, so emitting it would under-apply the rule. + _, err = fw.MarshalAdvRule(&Rule{Proto: ProtocolAny, Port: 80, Source: "192.0.2.1", Action: Accept}) + require.ErrorIs(t, err, ErrUnsupported) +} + +// TestAPFTCPUDPRouting pins where a both-transports rule goes. A FamilyAny bare port +// accept is native (both CPORTS lists are dual-stack); a single-family one, a +// multi-port one, and an address-less source-port match have no native form. +func TestAPFTCPUDPRouting(t *testing.T) { + fw := new(APF) + require.True(t, fw.barePortAccept(&Rule{Proto: TCPUDP, Port: 80, Action: Accept})) + require.False(t, fw.needsHook(&Rule{Proto: TCPUDP, Port: 80, Action: Accept}), + "a dual-stack tcpudp port accept lives in the CPORTS lists") + require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCPUDP, Port: 80, Action: Accept}), + "a single-family tcpudp port has no dual-stack CPORTS form") + // An address-less multi-port accept is native: the CPORTS lists are comma lists. + // The same ports against an address are not — apf's advanced rule holds one port. + require.False(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}), + "a dual-stack tcpudp port list lives in the CPORTS lists") + require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, + Source: "192.0.2.1", Action: Accept}), + "apf's advanced rule carries no port list") + require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, SourcePort: 80, Action: Accept}), + "an address-less source-port match has no advanced-rule form") + // A portless tcpudp host has no plain-line form (a plain line is all-protocol). + require.True(t, hostNeedsHook(&Rule{Proto: TCPUDP, Source: "192.0.2.1", Action: Accept})) +} diff --git a/backup.go b/backup.go index c485ef2..97c8f83 100644 --- a/backup.go +++ b/backup.go @@ -8,16 +8,10 @@ import ( "strings" ) -// This file makes a Backup portable: it can be serialized to and from JSON so a -// snapshot taken on one host can be replayed on another (or persisted to disk). -// -// The rule/NAT-rule structs are plain data, but their enum fields (Action, -// Family, Protocol, ...) are iota-based uint8 values. Left to encoding/json -// those would marshal as bare numbers, which are unreadable and would silently -// change meaning if a constant were ever reordered. They are therefore given -// MarshalJSON/UnmarshalJSON implementations that carry the canonical, stable -// string name each type's String method already emits. The two generic helpers -// below keep that boilerplate to one line per type. +// Backup JSON serialization so snapshots can be persisted to disk or moved +// between hosts. Enum values are marshaled as their stable string names to keep +// backups readable across library versions. The two generic helpers below keep +// the per-type boilerplate to one line. // marshalEnum renders an enum value as its canonical string name. func marshalEnum[T ~uint8](v T, str func(T) string) ([]byte, error) { diff --git a/capabilities_linux_test.go b/capabilities_linux_test.go deleted file mode 100644 index 816ce7b..0000000 --- a/capabilities_linux_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package firewall - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestCSFAPFStillUnsupported(t *testing.T) { - ctx := context.Background() - csf := &CSF{} - apf := &APF{} - - // Logging, rate limiting, connection-state and interface matching are now - // expressed by injecting iptables rules through the pre-hook (see - // 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.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. - dnat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} - require.ErrorIs(t, m.InsertNATRule(ctx, "", 1, dnat), ErrUnsupportedOrdering, - "%s should reject NAT ordering", m.Type()) - } - - // csf.redirect expresses only destination NAT, so a source NAT is rejected - // with the NAT sentinel before any file is touched. - snat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"} - require.ErrorIs(t, csf.AddNATRule(ctx, "", snat), ErrUnsupportedNAT, - "csf should reject source NAT") -} - -// Every unsupported-feature path wraps a sentinel error that callers can match -// with errors.Is. The umbrella ErrUnsupported matches them all. -func TestSentinelErrors(t *testing.T) { - ctx := context.Background() - csf := &CSF{} - - // 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) - require.ErrorIs(t, err, ErrUnsupported) - - _, err = csf.GetDefaultPolicy(ctx, "") - require.ErrorIs(t, err, ErrUnsupportedPolicy) - - // 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) - require.ErrorIs(t, err, ErrUnsupported) -} - -// Capabilities advertise each backend's supported features consistently with -// its actual behavior. -func TestCapabilities(t *testing.T) { - nft := &NFT{} - ipt := &IPTables{} - csf := &CSF{} - apf := &APF{} - - require.True(t, nft.Capabilities().RuleCounters, "nftables exposes rule counters") - require.True(t, nft.Capabilities().DefaultPolicy, "nftables manages a default policy") - require.True(t, nft.Capabilities().AddressSets, "nftables manages address sets") - require.True(t, nft.Capabilities().NAT) - - require.True(t, ipt.Capabilities().RuleCounters, "iptables exposes rule counters") - 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. - require.False(t, csf.Capabilities().RuleCounters) - require.False(t, csf.Capabilities().DefaultPolicy) - // 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, and address sets from ipset commands - // persisted in its pre-hook. - require.True(t, apf.Capabilities().NAT) - require.True(t, apf.Capabilities().ConnLimit) - 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. - for _, c := range []Capabilities{csf.Capabilities(), apf.Capabilities()} { - require.True(t, c.Logging) - require.True(t, c.RateLimit) - require.True(t, c.ConnState) - require.True(t, c.InterfaceMatch) - require.True(t, c.Forward) - } - - // nftables, iptables and ufw express forward-chain rules natively. - require.True(t, nft.Capabilities().Forward, "nftables expresses forward rules") - require.True(t, ipt.Capabilities().Forward, "iptables expresses forward rules") - - // CSF's ICMPv6 always goes through that same pre-hook (raw ip6tables), so it - // is unconditionally supported regardless of csf.conf's IPV6 setting. - require.True(t, csf.Capabilities().ICMPv6) - - // APF's plain ICMPv6 rules instead use its native IG_ICMPV6_TYPES/ - // EG_ICMPV6_TYPES lists, which apf itself silently no-ops unless conf.apf's - // USE_IPV6 is "1" — so the capability mirrors ipv6Enabled. - require.False(t, apf.Capabilities().ICMPv6, "USE_IPV6 not confirmed enabled, so ICMPv6 must not be advertised") - apfV6 := &APF{ipv6Enabled: true} - require.True(t, apfV6.Capabilities().ICMPv6) -} diff --git a/csf_linux.go b/csf_linux.go index 7a504fa..4663fd2 100644 --- a/csf_linux.go +++ b/csf_linux.go @@ -9,12 +9,9 @@ import ( "strconv" "strings" "time" - - dbus "github.com/coreos/go-systemd/dbus" ) const ( - CSFType = "csf" CSFConf = "/etc/csf/csf.conf" CSFAllow = "/etc/csf/csf.allow" CSFDeny = "/etc/csf/csf.deny" @@ -35,17 +32,17 @@ const ( // rules onto its config files (csf.conf, csf.allow, csf.deny, csf.redirect) and // a managed pre-hook for features csf's native config cannot express. type CSF struct { - // rulePrefix is prepended to a rule's comment when it is written as a - // full-line comment above a rule in csf.allow/csf.deny, so rules this - // library creates can be told apart. csf.conf port-list rules carry no - // per-rule comment and so ignore it. + // rulePrefix tags rules this library creates so they can be told apart + // from foreign rules. In csf.allow/csf.deny it is prepended to the + // comment written on the line above each rule; csf.conf port-list rules + // carry no per-rule comment and so cannot carry the tag. rulePrefix string - // ipv6Enabled mirrors csf.conf's IPV6. csf.pl's linefilter silently drops a - // csf.allow/csf.deny line (plain or advanced) that resolves to an IPv6 - // address whenever IPV6 is not "1" (the shipped default), so AddRule must - // reject that shape rather than write a line csf will never enforce. - // ICMPv6 is unaffected: it always routes through the raw-iptables hook, - // which runs outside csf's own IPV6-gated logic. + // ipv6Enabled mirrors csf.conf's IPV6. With it off (the shipped default) csf + // enforces no IPv6 at all: csf.pl's linefilter silently drops a csf.allow/ + // csf.deny line resolving to an IPv6 address, the TCP6_IN/UDP6_IN port lists + // go unread, and ip6tables is never flushed on (re)load. AddRule therefore + // rejects every concrete-IPv6 rule (see ipv6Unavailable) rather than write one + // csf will never enforce. ipv6Enabled bool } @@ -55,30 +52,9 @@ func NewCSF(ctx context.Context, rulePrefix string) (*CSF, error) { csf := new(CSF) csf.rulePrefix = rulePrefix - // Connect to systemd dbus interface. - conn, err := dbus.NewWithContext(ctx) - if err != nil { - return nil, fmt.Errorf("failed to connect to systemd: %s", err) - } - defer conn.Close() - - // Find the systemd service for csf and confirm it is set to start. CSF's - // installer detects a running systemd at install time and drops in a real - // static unit file, giving "enabled"/"enabled-runtime" — but when systemd - // isn't PID 1 at install time (e.g. a container image built without an init - // process) the installer falls back to its SysV /etc/init.d/csf script, and - // systemd's sysv-generator synthesizes a wrapper unit at boot, which reports - // as "generated" (confirmed against a real csf install: FragmentPath under - // /run/systemd/generator*, SourcePath /etc/init.d/csf). Both are a - // legitimately enabled csf.service, so both are accepted here. - prop, err := conn.GetUnitPropertyContext(ctx, "csf.service", "UnitFileState") - if err != nil { - return nil, fmt.Errorf("error getting csf service property: %s", err) - } - switch prop.Value.Value() { - case "enabled", "enabled-runtime", "generated": - // csf.service is present and set to start (native unit or SysV-backed). - default: + // Confirm csf is enabled under whatever init system the host uses + // (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local). + if !serviceEnabled(ctx, "csf") { return nil, fmt.Errorf("the csf service is not enabled on this server") } @@ -116,7 +92,10 @@ func (f *CSF) Capabilities() Capabilities { return Capabilities{ Output: true, Forward: true, - ICMPv6: true, + // ICMPv6 mirrors ipv6Enabled: with csf.conf's IPV6 off, csf never touches + // ip6tables, so neither its native config nor the raw-iptables hook yields a + // rule csf will keep in sync across a reload (see ipv6Unavailable). + ICMPv6: f.ipv6Enabled, // 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 @@ -486,12 +465,8 @@ func (f *CSF) dropActions() (dropIn, dropOut Action) { 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. +// 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, @@ -500,42 +475,6 @@ func (f *CSF) hook() *hookScript { } } -// 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) { @@ -632,18 +571,6 @@ func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err return nil, err } rules = append(rules, hookRules...) - - // Merge rules across families, then across protocol: a ProtocolAny port rule is - // stored as a tcp line plus a udp line (see the fan-out in EditIPList), so - // collapse such a pair back to one ProtocolAny rule or it never reads back as - // what was written and churns on every reconcile. - rules = mergeFamilies(rules) - rules = f.mergeProtocols(rules) - // Collapse each input/output twin into one DirAny rule — in particular a plain - // csf.allow/csf.deny IP, read as an inbound (source) rule plus an outbound - // (destination) rule, reads back as the single bidirectional line it was written - // as. Runs after the family/protocol merges so the twin is already canonicalized. - rules = mergeDirections(rules) return } @@ -896,15 +823,15 @@ func (f *CSF) MarshalAdvRule(r *Rule) (string, error) { 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) + // A single advanced line carries a single protocol token, so a TCPUDP port rule + // with an address cannot be expressed here: a protocol-less line is defaulted to + // `-p tcp` by csf.pl's linefilter, applying the rule to TCP only while the + // library reads it back as both transports — leaving UDP open on a deny (or + // unallowed on an accept). The address-less form is fanned into a tcp and a udp + // line by the caller (portOnlyDenyLines); one line cannot fan, so reject it here + // rather than under-apply it. + if r.Proto == TCPUDP { + return "", fmt.Errorf("a csf advanced rule cannot carry both transports; expand it to tcp and udp lines: %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 @@ -953,41 +880,34 @@ func (f *CSF) MarshalAdvRule(r *Rule) (string, error) { 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. +// advMatch reports whether a parsed advanced-rule line (the existing row) matches +// target. A TCPUDP port rule is written as a separate tcp line and udp line, so a +// TCPUDP target matches either concrete-transport line; that coverage, like the +// family and direction 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 parsed.EqualForRemoval(target, true) } - return p.EqualForDedup(target, true) + return parsed.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. +// address) fans out to: one per transport (tcp and udp for a TCPUDP 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 protos []string + for _, sub := range expandProtocols(r) { + protos = append(protos, sub.Proto.String()) } var placeholders []string switch r.impliedFamily() { @@ -1013,9 +933,10 @@ func (f *CSF) portOnlyDenyLines(r *Rule) []string { 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. +// EditIPList adds or removes a rule in a csf.allow/csf.deny list, rewriting it in +// place. A port-only deny fans out per transport and family placeholder, so a +// TCPUDP rule reads back as the rule that was written rather than being re-added on +// every reconcile and never 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) @@ -1203,11 +1124,11 @@ func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r // // csf's advanced-rule handler only emits an iptables rule when the line carries // an address, so each fan-out line uses the "any" network placeholder (0.0.0.0/0 - // or ::/0) as the address; parseAddr normalizes it back to an empty address - // and mergeFamilies collapses the v4/v6 pair to FamilyAny on read, keeping the - // rule readable and removable. The transport is named explicitly (a - // protocol-less line defaults to tcp in csf's linefilter), and a ProtocolAny - // deny fans to both tcp and udp. + // or ::/0) as the address; parseAddr normalizes it back to an empty address on + // read, so each family's line reads back as its own rule. The transport is named + // explicitly (a + // protocol-less line defaults to tcp in csf's linefilter), and a TCPUDP deny fans + // to both tcp and udp. if len(wantDeny) > 0 { writeComment := func() { if c := combineComment(f.rulePrefix, r.Comment); c != "" { @@ -1250,8 +1171,8 @@ func (f *CSF) checkConnLimit(r *Rule) error { // (`--icmp-type `), which csf then fails to parse and drops silently (csf.pl // linefilter). There is no csf advanced-rule encoding for "any icmp type from an // address" — a bare host rule already covers all protocols — so reject it rather -// than emit a dropped line. ICMPv6 never reaches this check: ruleNeedsHook routes -// it to the pre-hook before addRule/RemoveRule call checkICMP. +// than emit a dropped line. ICMPv6 never reaches this check: ruleNeedsHook routes it +// to the pre-hook (or the IPv6 gate rejects it) before addRule/RemoveRule call checkICMP. func (f *CSF) checkICMP(r *Rule) error { if r.Proto == ICMP { if r.Source == "" && r.Destination == "" { @@ -1264,15 +1185,16 @@ 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. +// checkPortProto rejects a port match on a 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 lists, and a +// port on ProtocolAny — which matches every IP protocol, not just the two csf can +// carry — has no faithful form at all. TCPUDP is allowed: it fans out to both lists +// on an address-less accept, and to a tcp and a udp csf.deny advanced line +// otherwise. func (f *CSF) checkPortProto(r *Rule) error { switch r.Proto { - case TCP, UDP, ProtocolAny: + case TCP, UDP, TCPUDP: return nil } if r.HasPorts() || r.HasSourcePorts() { @@ -1304,18 +1226,19 @@ func (f *CSF) denyAction(output bool) Action { 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 -func (f *CSF) ipv6Unavailable(r *Rule) bool { - return !f.ipv6Enabled && r.Proto != ICMPv6 && r.impliedFamily() == IPv6 -} - // 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. func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6Gate bool) error { + // Reject a concrete-IPv6 rule when csf's own IPv6 handling is off, ahead of every + // routing decision below: neither csf's config nor the pre-hook can carry one that + // csf will keep in sync (see ipv6Unavailable). Checking here rather than past the + // hook branches also keeps a DirAny rule from writing its input half before its + // output half is rejected. + if enforceIPv6Gate && ipv6Unavailable(f.ipv6Enabled, r) { + return fmt.Errorf("csf's IPv6 handling is disabled (csf.conf IPV6 is not \"1\"): %w", ErrUnsupported) + } + // A DirAny rule maps to a single native construct only as a bare-host plain line; // every other DirAny shape fans out into a concrete input rule plus its swapped // output rule, each routed independently (a half may itself need the hook). @@ -1328,6 +1251,21 @@ func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6 return nil } + // csf has no both-transports construct: its port lists are a TCP list and a UDP + // list, and csf.pl's linefilter silently reads a protocol-less advanced line as + // `-p tcp`. So a TCPUDP rule fans out into a tcp rule and a udp rule, each routed + // independently, and each reads back as its own rule. Unlike apf — whose trust + // parser derives both transports from one protocol-less line — csf cannot express + // the pair in a single line anywhere. + if r.Proto == TCPUDP { + for _, sub := range expandProtocols(r) { + if err := f.addRule(ctx, zoneName, sub, enforceIPv6Gate); err != nil { + return err + } + } + return nil + } + // Features csf's native config cannot express (connection-state, per-rule // interface, logging, rate limiting, icmpv6) are injected as iptables rules // through the csf pre-hook. @@ -1351,16 +1289,12 @@ func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6 // A bare protocol match with no address and no port has no native csf construct — // csf.conf keys on a port, an advanced rule on address+port, and csf.allow/csf.deny // on an address — but iptables expresses it directly, so it is injected through the - // pre-hook (see bareProtoNeedsHook). It is checked ahead of the IPv6 gate because - // the hook runs ip6tables directly, outside csf.conf's IPV6-gated logic. ICMP keeps - // its own handling (checkICMP) and is excluded there. + // pre-hook (see bareProtoNeedsHook). ICMP keeps its own handling (checkICMP) and is + // excluded there. if bareProtoNeedsHook(r) { _, err := f.hook().edit(r, false) return err } - if enforceIPv6Gate && f.ipv6Unavailable(r) { - return fmt.Errorf("csf's IPv6 handling is disabled (csf.conf IPV6 is not \"1\"): %w", ErrUnsupported) - } if err := f.checkSourcePort(r); err != nil { return err } @@ -1485,6 +1419,19 @@ func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { return nil } + // A TCPUDP target fans out into its two concrete-transport rules, mirroring + // addRule, so each is removed from whichever list or line it was written to. A + // caller removing one transport targets that transport directly and leaves the + // other in place. + if r.Proto == TCPUDP { + for _, sub := range expandProtocols(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + // Hook-injected rules (see AddRule) are removed from the managed script. if ruleNeedsHook(r) { _, err := f.hook().edit(r, true) @@ -1663,8 +1610,7 @@ func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, err // csf.redirect is CSF's own NAT config with no per-rule prefix marker, so no // rule in it carries the configured prefix; HasPrefix stays false (mirroring // firewalld's zones). - merged := mergeNATFamilies(rules) - return merged, nil + return rules, nil } // redirectAddr renders an address for a csf.redirect field, using "*" for an @@ -1817,6 +1763,63 @@ func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) er return f.editRedirect(r, true) } +// GetDefaultPolicy is unsupported: csf exposes no chain default policy. +func (f *CSF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + return nil, unsupportedPolicy(f.Type()) +} + +// SetDefaultPolicy is unsupported: csf exposes no chain default policy. +func (f *CSF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + return unsupportedPolicy(f.Type()) +} + +// GetAddressSets returns the address sets carried by the csf pre-hook. +func (f *CSF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + return f.hook().getAddressSets() +} + +// GetAddressSet returns a single address set by name, or an error if absent. +func (f *CSF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + 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 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 { + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + _, err := f.hook().editAddressSet(set, false) + return err +} + +// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a +func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error { + _, err := f.hook().editAddressSet(&AddressSet{Name: name}, true) + return err +} + +// AddAddressSetEntry adds an entry to an existing set in the pre-hook. +func (f *CSF) AddAddressSetEntry(ctx context.Context, name, entry string) error { + _, err := f.hook().editAddressSetEntry(name, entry, false) + return err +} + +// RemoveAddressSetEntry removes an entry from an existing set in the pre-hook. +func (f *CSF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + _, err := f.hook().editAddressSetEntry(name, entry, true) + return err +} + // 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) @@ -1884,64 +1887,6 @@ func (f *CSF) Restore(ctx context.Context, zoneName string, backup *Backup) erro return nil } -// GetDefaultPolicy is unsupported: csf exposes no chain default policy. -func (f *CSF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { - return nil, unsupportedPolicy(f.Type()) -} - -// SetDefaultPolicy is unsupported: csf exposes no chain default policy. -func (f *CSF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { - return unsupportedPolicy(f.Type()) -} - -// GetAddressSets returns the address sets carried by the csf pre-hook. -func (f *CSF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { - return f.hook().getAddressSets() -} - -// GetAddressSet returns a single address set by name, or an error if absent. -func (f *CSF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { - 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 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 { - if set == nil || set.Name == "" { - return fmt.Errorf("an address set requires a name") - } - _, err := f.hook().editAddressSet(set, false) - return err -} - -// 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 { - _, err := f.hook().editAddressSet(&AddressSet{Name: name}, true) - return err -} - -// AddAddressSetEntry adds an entry to an existing set in the pre-hook. -func (f *CSF) AddAddressSetEntry(ctx context.Context, name, entry string) error { - _, err := f.hook().editAddressSetEntry(name, entry, false) - return err -} - -// RemoveAddressSetEntry removes an entry from an existing set in the pre-hook. -func (f *CSF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { - _, 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 { diff --git a/csf_linux_test.go b/csf_linux_test.go index cf0150f..0c03d5c 100644 --- a/csf_linux_test.go +++ b/csf_linux_test.go @@ -10,43 +10,18 @@ import ( "github.com/stretchr/testify/require" ) -// A port-only IPv6 deny carries no address (its ::/0 is synthesized on write), -// so the IPV6-disabled gate must key on the implied family alone. Otherwise the -// line is written, csf.pl drops it as a v6-resolving line under IPV6!=1, and the -// port stays open while GetRules reports it blocked. -func TestCSFIPv6UnavailablePortOnlyDeny(t *testing.T) { - fw := new(CSF) // ipv6Enabled defaults to false — the IPV6-disabled case. - - // The regression: a port-only IPv6 deny (no Source/Destination) must be caught. - require.True(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop}), - "port-only IPv6 deny slipped past the IPV6-disabled gate") - - // A v6-address rule was already caught and must stay caught. - require.True(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Drop})) - - // Not caught: a family-agnostic port-only deny (its 0.0.0.0/0 twin is enforced), - // a plain IPv4 rule, and ICMPv6 (which routes through the raw-iptables hook). - require.False(t, fw.ipv6Unavailable(&Rule{Proto: TCP, Port: 8080, Action: Drop})) - require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop})) - require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept})) - - // With IPv6 enabled the gate never fires. - fw.ipv6Enabled = true - require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop})) -} - -// A ProtocolAny port-only reject must be written to csf.deny as explicit tcp and +// A TCPUDP port-only reject must be written to csf.deny as explicit tcp and // udp advanced lines: csf's linefilter defaults a protocol-less line to -p tcp, // so a single protocol-less line would leave udp open while the library reported // the port blocked for all protocols. -func TestCSFProtocolAnyRejectFansOut(t *testing.T) { +func TestCSFTCPUDPRejectFansOut(t *testing.T) { ctx := context.Background() fw := new(CSF) dir := t.TempDir() path := filepath.Join(dir, "csf.deny") require.NoError(t, os.WriteFile(path, nil, 0644)) - reject := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 80, Action: Reject} + reject := &Rule{Family: IPv4, Proto: TCPUDP, Port: 80, Action: Reject} require.NoError(t, fw.EditIPList(ctx, path, Reject, reject, false)) data, err := os.ReadFile(path) @@ -81,21 +56,21 @@ func TestCSFPortOnlyDropDenyIsWritten(t *testing.T) { "a port-only Drop deny must be written with the any-network placeholder") } -// A ProtocolAny port deny is written as a tcp line and a udp line, so it must be -// idempotent on re-add, read back as a single ProtocolAny rule, and be fully +// A TCPUDP port deny is written as a tcp line and a udp line, so it must be +// idempotent on re-add, read back as a single TCPUDP rule, and be fully // removed by one RemoveRule. Before the fix the add/remove matcher compared -// ProtocolAny against the concrete-protocol lines exactly, so re-adds duplicated +// TCPUDP against the concrete-protocol lines exactly, so re-adds duplicated // the pair and removal was a silent no-op. -func TestCSFProtocolAnyPortDenyRoundTrip(t *testing.T) { +func TestCSFTCPUDPPortDenyRoundTrip(t *testing.T) { ctx := context.Background() fw := new(CSF) dir := t.TempDir() path := filepath.Join(dir, "csf.deny") require.NoError(t, os.WriteFile(path, nil, 0644)) - deny := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 80, Action: Drop} + deny := &Rule{Family: IPv4, Proto: TCPUDP, Port: 80, Action: Drop} - // Add fans the ProtocolAny deny out to a tcp and a udp line. + // Add fans the TCPUDP deny out to a tcp and a udp line. require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false)) data, err := os.ReadFile(path) require.NoError(t, err) @@ -107,24 +82,24 @@ func TestCSFProtocolAnyPortDenyRoundTrip(t *testing.T) { data, err = os.ReadFile(path) require.NoError(t, err) require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=80|s=0.0.0.0/0"), - "re-adding a ProtocolAny deny must not duplicate its tcp line") + "re-adding a TCPUDP deny must not duplicate its tcp line") require.Equal(t, 1, strings.Count(string(data), "udp|in|d=80|s=0.0.0.0/0"), - "re-adding a ProtocolAny deny must not duplicate its udp line") + "re-adding a TCPUDP deny must not duplicate its udp line") - // The fanned lines read back and collapse to one ProtocolAny rule. + // The fanned lines read back as their own rules and cover the TCPUDP deny. parsed, err := fw.ParseIPList(path, Drop) require.NoError(t, err) - merged := fw.mergeProtocols(mergeFamilies(parsed)) - require.Len(t, merged, 1, "the tcp+udp deny lines must merge to one ProtocolAny rule") - require.Equal(t, ProtocolAny, merged[0].Proto) - require.True(t, merged[0].EqualBase(deny, true), "the merged rule must equal the ProtocolAny deny that was added") + require.True(t, deny.CoveredBy(parsed), "the tcp+udp deny lines must cover the TCPUDP rule") + for _, g := range parsed { + require.True(t, deny.Covers(g), "a fanned line must not widen the rule: %+v", g) + } // A single RemoveRule must drop every fanned line. require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true)) data, err = os.ReadFile(path) require.NoError(t, err) require.NotContains(t, string(data), "d=80", - "removing a ProtocolAny deny must delete all of its fanned lines") + "removing a TCPUDP deny must delete all of its fanned lines") } // A port-only deny fans out across family (and protocol), but the file may already @@ -155,10 +130,10 @@ func TestCSFPortOnlyDenyHealsMissingFamily(t *testing.T) { require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=::/0"), "the missing IPv6 line must be added so IPv6:80 is actually blocked") - // A ProtocolAny deny whose udp line already exists must add the missing tcp line. + // A TCPUDP deny whose udp line already exists must add the missing tcp line. path2 := filepath.Join(dir, "csf.deny2") require.NoError(t, os.WriteFile(path2, []byte("udp|in|d=53|s=0.0.0.0/0\n"), 0644)) - anyDeny := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 53, Action: Drop} + anyDeny := &Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Drop} require.NoError(t, fw.EditIPList(ctx, path2, Drop, anyDeny, false)) data2, err := os.ReadFile(path2) require.NoError(t, err) @@ -168,16 +143,16 @@ func TestCSFPortOnlyDenyHealsMissingFamily(t *testing.T) { "the missing tcp line must be added so tcp:53 is actually blocked") } -// A csf advanced rule with an address, a port, and ProtocolAny cannot be +// A csf advanced rule with an address, a port, and TCPUDP cannot be // expressed as a single line: csf.pl defaults a protocol-less line to tcp, so // udp would be silently left open. MarshalAdvRule must reject it rather than // under-apply it (the port-only form fans out instead, but that path has no // address to place). -func TestCSFAdvRuleProtocolAnyWithAddressRejected(t *testing.T) { +func TestCSFAdvRuleTCPUDPWithAddressRejected(t *testing.T) { fw := new(CSF) - _, err := fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: ProtocolAny, Port: 443, Source: "192.0.2.10", Action: Drop}) + _, err := fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: TCPUDP, Port: 443, Source: "192.0.2.10", Action: Drop}) require.ErrorIs(t, err, ErrUnsupported, - "an address+port rule with ProtocolAny must be rejected, not written tcp-only") + "an address+port rule with TCPUDP must be rejected, not written tcp-only") // A concrete protocol on the same shape is fine. _, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Drop}) @@ -554,31 +529,6 @@ func TestCSFRemovePreservesForeignHeader(t *testing.T) { require.Contains(t, got, "# Section: web servers", "the foreign section header must be preserved") } -// csf.pl's linefilter drops a csf.allow/csf.deny line (plain or advanced) that -// resolves to an IPv6 address whenever csf.conf's IPV6 is not "1" (the shipped -// default). ipv6Unavailable must flag exactly that shape, and only when -// ipv6Enabled is false; ICMPv6 (always hook-routed) must never be flagged. -func TestCSFIPv6UnavailableGate(t *testing.T) { - disabled := &CSF{ipv6Enabled: false} - enabled := &CSF{ipv6Enabled: true} - - bareV6Host := &Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept} - require.True(t, disabled.ipv6Unavailable(bareV6Host), - "an IPv6 rule must be blocked when csf.conf IPV6 is off") - require.False(t, enabled.ipv6Unavailable(bareV6Host), - "an IPv6 rule must be allowed when csf.conf IPV6 is on") - - bareV4Host := &Rule{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept} - require.False(t, disabled.ipv6Unavailable(bareV4Host), - "an IPv4 rule must never be blocked by the IPv6 gate") - - // ICMPv6 always routes through the raw-iptables hook, which runs outside - // csf's own IPV6-gated logic, so it must not be blocked either way. - icmpv6 := &Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept} - require.False(t, disabled.ipv6Unavailable(icmpv6), - "an icmpv6 rule must not be blocked by the IPv6 gate") -} - // csf.deny encodes no action of its own, so a rule added with Action Drop must be // found and removed by the same Drop rule rather than leaking. func TestCSFDropRuleRemovable(t *testing.T) { @@ -607,18 +557,20 @@ func TestCSFDualAddressRejected(t *testing.T) { } // csf's port lists are TCP_IN/UDP_IN; a concrete non-tcp/udp protocol (sctp) would -// wrongly be written into both, so it must error. ProtocolAny is allowed (it maps -// to both lists as the faithful "any" expansion). +// wrongly be written into both, so it must error. ProtocolAny carries a port on no +// protocol csf can express — it matches every IP protocol — so it errors too. Only +// TCPUDP maps to both lists. func TestCSFPortProtoGuard(t *testing.T) { fw := new(CSF) require.Error(t, fw.checkPortProto(&Rule{Proto: SCTP, Port: 80, Action: Accept}), "sctp port must be rejected") - require.NoError(t, fw.checkPortProto(&Rule{Port: 80, Action: Accept}), "ProtocolAny port is allowed for csf") + require.Error(t, fw.checkPortProto(&Rule{Port: 80, Action: Accept}), "an any-protocol port must be rejected") + require.NoError(t, fw.checkPortProto(&Rule{Proto: TCPUDP, Port: 80, Action: Accept}), "tcpudp port fans to both lists") require.NoError(t, fw.checkPortProto(&Rule{Proto: TCP, Port: 80, Action: Accept})) require.NoError(t, fw.checkPortProto(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept})) } // csf.conf's CONNLIMIT is a single dual-stack config key (it caps both v4 and v6 -// connections; there is no v6 variant to merge), so a connection-limit rule read +// connections; there is no separate v6 variant), so a connection-limit rule read // from it must be FamilyAny — not IPv4 — or a FamilyAny desired connlimit rule // (the natural shape: no address, so no family is implied) never matches its own // read-back and Sync removes-and-re-adds it every reconcile, firing csf -r each @@ -696,8 +648,8 @@ func TestCSFBareProtocolRoutesToHook(t *testing.T) { // and then silently never applied — the port stayed open while the library // reported it blocked. The rule must be written with the "any" network as the // address (so csf enforces it) and must still round-trip and remove: parseAddr -// normalizes the "any" network back to an empty address, and mergeFamilies -// collapses the v4/v6 pair a family-neutral rule writes. +// normalizes the "any" network back to an empty address, and a family-neutral rule +// writes one line per family, which cover it between them. func TestCSFPortOnlyRejectRoundTrip(t *testing.T) { fw := new(CSF) ctx := context.Background() @@ -718,12 +670,18 @@ func TestCSFPortOnlyRejectRoundTrip(t *testing.T) { require.True(t, strings.Contains(string(raw), "0.0.0.0/0") || strings.Contains(string(raw), "::/0"), "port-only reject (%s) must be written with an address so csf enforces it; got:\n%s", rule.Family, raw) - // GetRules applies mergeFamilies to the parsed list; mirror it here. + // A concrete-family rule is one line; a family-neutral one is a line per family. + wantRows := 1 + if rule.impliedFamily() == FamilyAny { + wantRows = 2 + } got, err := fw.ParseIPList(deny, Reject) require.NoError(t, err) - got = mergeFamilies(got) - require.Len(t, got, 1, "port-only reject (%s) must round-trip to one rule", rule.Family) - require.True(t, rule.Equal(got[0], false), "read-back rule must equal the written one: %+v", got[0]) + require.Len(t, got, wantRows, "port-only reject (%s) must round-trip to %d row(s)", rule.Family, wantRows) + require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule: %+v", got) + for _, g := range got { + require.True(t, rule.Covers(g), "read-back row must not widen the written rule: %+v", g) + } // It must also be removable (matched back on delete). require.NoError(t, fw.EditIPList(ctx, deny, Reject, rule, true)) @@ -810,53 +768,36 @@ func TestCSFFamilyAnyAdvDenyRoundTrip(t *testing.T) { require.NotContains(t, string(data), "d=22", "a FamilyAny removal must clear both placeholder lines") } -// TestCSFMergeProtocolsRespectsFamily guards mergeProtocols against collapsing a -// tcp/udp pair that differ in IP family. CSF expresses IPv4 and IPv6 opens through -// separate config keys (TCP_IN vs TCP6_IN), so a `TCP_IN="53"` + `UDP6_IN="53"` -// config produces a tcp/IPv4 rule and a udp/IPv6 rule. Those cover different -// families and must NOT merge into one ProtocolAny rule — doing so drops the IPv6 -// coverage from the read-back and makes Sync churn forever. -func TestCSFMergeProtocolsRespectsFamily(t *testing.T) { - fw := new(CSF) - // tcp/IPv4 and udp/IPv6, both port 53 inbound — same in every field but proto - // and family. - tcpV4 := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept} - udpV6 := &Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept} +// CSF expresses IPv4 and IPv6 opens through separate config keys (TCP_IN vs +// TCP6_IN), so a `TCP_IN="53"` + `UDP6_IN="53"` config produces a tcp/IPv4 rule and +// a udp/IPv6 rule. Those cover different families, and neither a TCPUDP/IPv4 rule nor +// its IPv6 twin may be reported as present against them — treating the pair as one +// both-transports rule drops a family's coverage and makes Sync churn forever. +func TestCSFCrossFamilyPairCoversNeitherTransportPair(t *testing.T) { + stored := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 53, Action: Accept}, + {Family: IPv6, Proto: UDP, Port: 53, Action: Accept}, + } - out := fw.mergeProtocols([]*Rule{tcpV4, udpV6}) + require.False(t, (&Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored), + "udp/IPv6 must not stand in for the missing udp/IPv4 open") + require.False(t, (&Rule{Family: IPv6, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored), + "tcp/IPv4 must not stand in for the missing tcp/IPv6 open") + require.False(t, (&Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored)) - if len(out) != 2 { - t.Fatalf("a cross-family tcp/udp pair must not merge: got %d rules %+v, want 2", len(out), out) - } - // Both families must still be represented. - var haveV4, haveV6 bool - for _, r := range out { - switch r.impliedFamily() { - case IPv4: - haveV4 = true - case IPv6: - haveV6 = true - } - } - if !haveV4 || !haveV6 { - t.Fatalf("both families must survive: haveV4=%v haveV6=%v (%+v)", haveV4, haveV6, out) - } + // Each stored rule still covers exactly its own cell. + require.True(t, (&Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}).CoveredBy(stored)) + require.True(t, (&Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept}).CoveredBy(stored)) } -// TestCSFMergeProtocolsSameFamily confirms the intended merge still happens for a -// same-family tcp/udp pair (the fanned-out form of one ProtocolAny port rule). -func TestCSFMergeProtocolsSameFamily(t *testing.T) { - fw := new(CSF) - tcp := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept} - udp := &Rule{Family: IPv4, Proto: UDP, Port: 53, Action: Accept} - - out := fw.mergeProtocols([]*Rule{tcp, udp}) - if len(out) != 1 { - t.Fatalf("a same-family tcp/udp pair should merge to one rule: got %d %+v", len(out), out) - } - if out[0].Proto != ProtocolAny { - t.Fatalf("merged rule should be ProtocolAny, got %v", out[0].Proto) +// A same-family tcp/udp pair — the fanned-out form csf writes for one TCPUDP port +// rule — does cover that rule. +func TestCSFSameFamilyPairCoversTCPUDP(t *testing.T) { + stored := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 53, Action: Accept}, + {Family: IPv4, Proto: UDP, Port: 53, Action: Accept}, } + require.True(t, (&Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored)) } // GetRules reports both the library's own rules and foreign ones, each tagged diff --git a/firewall.go b/firewall.go index f3c4cee..bf26ed9 100644 --- a/firewall.go +++ b/firewall.go @@ -144,6 +144,14 @@ const ( GRE ESP AH + // TCPUDP matches TCP and UDP together. It is the protocol analog of FamilyAny + // and DirAny: a backend whose native syntax carries both transports in one row + // stores it as written and reads it back as TCPUDP, while one that cannot fans it + // into a TCP row and a UDP row on write (expandProtocols). It is distinct from + // ProtocolAny, which matches *every* IP protocol — a tcp/udp rule reported as + // "any protocol" would silently widen to ICMP, GRE and the rest. Only TCPUDP + // carries ports. + TCPUDP ) // String returns the canonical lower-case name of the protocol. @@ -153,6 +161,8 @@ func (t Protocol) String() string { return "udp" case TCP: return "tcp" + case TCPUDP: + return "tcpudp" case ICMP: return "icmp" case ICMPv6: @@ -174,10 +184,26 @@ func (t Protocol) IsICMP() bool { return t == ICMP || t == ICMPv6 } -// HasPorts reports whether the protocol carries layer-4 ports (TCP, UDP or -// SCTP). A port match is only meaningful and only valid for these protocols. +// HasPorts reports whether the protocol carries layer-4 ports (TCP, UDP, SCTP or +// the merged TCPUDP). A port match is only meaningful and only valid for these +// protocols. func (t Protocol) HasPorts() bool { - return t == TCP || t == UDP || t == SCTP + return t == TCP || t == UDP || t == SCTP || t == TCPUDP +} + +// oppositeProtocol returns the other transport of the TCP/UDP pair a TCPUDP rule +// fans out to: UDP for TCP and vice versa. Every other protocol has no twin and +// returns ProtocolAny (the sentinel meaning "no pair"). It is the protocol analog of +// oppositeFamily, and supports the dual-row split on removal. +func oppositeProtocol(p Protocol) Protocol { + switch p { + case TCP: + return UDP + case UDP: + return TCP + default: + return ProtocolAny + } } // Ptr returns a pointer to v. It is a convenience for setting optional rule @@ -286,6 +312,8 @@ func GetProtocol(proto string) Protocol { return UDP case strings.EqualFold("tcp", proto): return TCP + case strings.EqualFold("tcpudp", proto): + return TCPUDP case strings.EqualFold("icmp", proto): return ICMP case strings.EqualFold("icmpv6", proto), @@ -790,10 +818,10 @@ type Rule struct { // (Capabilities().RuleOrdering). It mirrors the position argument of InsertRule // and MoveRule, so a caller can read a rule's Number and pass it back to reorder. // It is zero on backends without ordering and on rules outside the backend's - // ordered set (foreign rules in a container backend's read). For a rule merged - // across IPv4 and IPv6 it reflects the IPv4 chain. Like HasPrefix and - // Packets/Bytes it is derived on read, ignored when adding a rule, and not part - // of rule identity. + // ordered set (foreign rules in a container backend's read). A rule the backend + // stores as one row per IP family is reported once per row, each with its own + // Number. Like HasPrefix and Packets/Bytes it is derived on read, ignored when + // adding a rule, and not part of rule identity. Number int // table records the backend container a container backend (an nft table, a pf // anchor, a firewalld zone) read this rule from; it is empty for tag-based @@ -805,9 +833,9 @@ type Rule struct { // directionFromOutput maps an input/output boolean to a Direction. Backends whose // native config distinguishes only inbound from outbound use it when decoding a // rule (a routed/forward rule is expressed through their raw-iptables hook, not -// their native config, so it is never decoded here). It only ever yields a -// concrete DirInput or DirOutput; DirAny is synthesized later by the read-side -// direction merge (see mergeDirections), never by a single-row decode. +// their native config, so it is never decoded here). It only ever yields a concrete +// DirInput or DirOutput; a backend reports DirAny only for an entry whose native +// form covers both directions, which it decodes itself. func directionFromOutput(output bool) Direction { if output { return DirOutput @@ -929,9 +957,10 @@ func (r *Rule) impliedFamily() Family { // the in/out interface all swap sides. Everything protocol- or policy-bound (Proto, // ICMPType, State, Action, Log, rate/conn limits, Family, Priority, Comment, // counters, Number, table) is direction-independent and is left untouched. It backs -// both the DirAny write-side fan-out (expandDirections) and the read-side merge -// (mergeDirections). The Ports/SourcePorts slice headers are swapped, not their -// elements; callers do not mutate them, matching splitDualRow's shallow-copy style. +// the DirAny write-side fan-out (expandDirections), the direction split on removal, +// and the inbound-frame comparison rule identity uses. The Ports/SourcePorts slice +// headers are swapped, not their elements; callers do not mutate them, matching +// splitDualRow's shallow-copy style. func (r *Rule) directionSwapped() *Rule { s := *r s.Source, s.Destination = r.Destination, r.Source @@ -1143,25 +1172,63 @@ func coversDirectionRemoval(a, b Direction, outputHonored bool) bool { // with direction excluded from the field compare since coversDirection already // gated it. func (r *Rule) EqualForDedup(o *Rule, outputHonored bool) bool { - fe, fr := r.impliedFamily(), o.impliedFamily() - if !(fe == FamilyAny || fe == fr) { + return r.covers(o, outputHonored) +} + +// coversFamily reports whether an existing rule's family have already covers a caller +// rule's family want. FamilyAny spans both IP families, so it covers either; a +// concrete family covers only itself. Both sides are implied families, so an +// address- or ICMP-pinned rule is compared by the family it actually targets. +func coversFamily(have, want Family) bool { + return have == FamilyAny || have == want +} + +// covers is the coverage relation behind Covers and EqualForDedup: r's match is the +// same as o's in every ordinary field, and r's family, transport and direction each +// span o's. outputHonored is false on a backend with no output concept, where +// direction never distinguishes two rules. +func (r *Rule) covers(o *Rule, outputHonored bool) bool { + if !coversFamily(r.impliedFamily(), o.impliedFamily()) { return false } if !coversDirection(r.Direction, o.Direction, outputHonored) { return false } - return r.canonicalMatch().EqualBase(o.canonicalMatch(), false) + if !coversProtocol(r.Proto, o.Proto) { + return false + } + // Protocol is gated above, so neutralize it on the tcp/udp axis rather than let + // matchFields re-test it exactly — a TCPUDP row must absorb a concrete TCP add. + return r.canonicalMatch().protoNeutralized().EqualBase(o.canonicalMatch().protoNeutralized(), false) +} + +// Covers reports whether the receiver's coverage contains o's: the same match in +// every ordinary field, with the receiver's family, transport and direction each +// spanning o's. FamilyAny spans both IP families, TCPUDP spans TCP and UDP, and +// DirAny spans input and output; a concrete value spans only itself. ProtocolAny is +// not a multi-state value — it matches every IP protocol — so it covers only +// ProtocolAny. +// +// It is the exported form of the coverage relation the library reasons with. A caller +// holding a rule read back from GetRules uses it to tell whether that rule already +// contains one it is about to add, rather than re-deriving the per-axis rules. It is +// asymmetric: a TCPUDP rule covers its TCP half, never the reverse. Direction is +// always honored; a backend that has no output concept reports Capabilities().Output +// false and folds a DirAny rule to its input half on write. +func (r *Rule) Covers(o *Rule) bool { + return r.covers(o, true) } // EqualForRemoval reports whether the receiver (an existing row) should be acted -// on when the caller targets o: the same base rule, and o's family and direction -// may touch the row's. It is the family- and direction-aware remove/move guard for -// the backends whose GetRules merges a v4/v6 pair into one FamilyAny rule or an -// in/out pair into one DirAny rule — a FamilyAny/DirAny target matches every row on -// that axis, a FamilyAny/DirAny row matches any target, and otherwise the values -// must match so acting on one twin never disturbs the other. Family and direction -// are checked first so a non-matching row skips the field compare, which runs in -// the inbound frame (canonicalMatch) with direction excluded. +// on when the caller targets o: the same base rule, and o's family, transport and +// direction each touch the row's. It is the overlap relation a removal walks the +// stored rows with, so a target removes every row it covers (rule 3) and also +// matches a row that covers more than the target, which the backend then deletes and +// re-adds minus the targeted cell (splitMergedRow, rule 4). A FamilyAny/TCPUDP/DirAny +// target matches every row on that axis, such a row matches any target, and otherwise +// the values must match so acting on one twin never disturbs the other. Family and +// direction are checked first so a non-matching row skips the field compare, which +// runs in the inbound frame (canonicalMatch) with direction excluded. func (r *Rule) EqualForRemoval(o *Rule, outputHonored bool) bool { ft, fr := o.impliedFamily(), r.impliedFamily() if !(ft == FamilyAny || fr == FamilyAny || ft == fr) { @@ -1170,7 +1237,13 @@ func (r *Rule) EqualForRemoval(o *Rule, outputHonored bool) bool { if !coversDirectionRemoval(r.Direction, o.Direction, outputHonored) { return false } - return r.canonicalMatch().EqualBase(o.canonicalMatch(), false) + if !coversProtocolRemoval(r.Proto, o.Proto) { + return false + } + // Protocol is gated above; neutralize it on the tcp/udp axis so a TCPUDP row + // matches a concrete-transport target (the caller then splits it) and a TCPUDP + // target matches each concrete row it covers. + return r.canonicalMatch().protoNeutralized().EqualBase(o.canonicalMatch().protoNeutralized(), false) } // oppositeFamily returns the other concrete IP family: IPv4 for IPv6 and vice @@ -1207,21 +1280,6 @@ func splitDualRow(matched, target *Rule) *Rule { return &opp } -// oppositeDirection returns the other concrete traffic direction: DirOutput for -// DirInput and vice versa. DirForward and DirAny have no opposite and return -// DirAny (the sentinel the direction merge treats as "no pair"). It supports the -// direction merge and the dual-row split on removal. -func oppositeDirection(d Direction) Direction { - switch d { - case DirInput: - return DirOutput - case DirOutput: - return DirInput - default: - return DirAny - } -} - // expandDirections returns the concrete-direction rows a rule materializes into on // write: itself for a concrete direction, or an inbound (DirInput) row plus its // role-swapped outbound (DirOutput) twin for a DirAny rule. Backends call it before @@ -1238,6 +1296,185 @@ func expandDirections(r *Rule) []*Rule { return []*Rule{&in, out} } +// expandProtocols returns the concrete-transport rows a rule materializes into on +// write: itself for a single protocol, or a TCP row plus a UDP row for a TCPUDP +// rule. Backends whose native config has no both-transports form call it before +// marshalling so per-row emission never has to reason about TCPUDP. The returned +// rows are copies; the caller's rule is untouched. It is the protocol analog of +// expandDirections. +func expandProtocols(r *Rule) []*Rule { + if r.Proto != TCPUDP { + return []*Rule{r} + } + tcp, udp := *r, *r + tcp.Proto, udp.Proto = TCP, UDP + return []*Rule{&tcp, &udp} +} + +// expandFamilies returns the concrete-family rows a rule materializes into: itself +// when it already targets one family, or an IPv4 row plus an IPv6 row when it targets +// both. It reads the implied family, so a rule pinned by an address or an ICMP +// protocol is never split. Backends fan families out in their own way (a save file +// per family, a family-less inet row, a dual-stack config list), so this exists for +// the coverage math in cells/CoveredBy rather than for any write path. +func expandFamilies(r *Rule) []*Rule { + if r.impliedFamily() != FamilyAny { + return []*Rule{r} + } + v4, v6 := *r, *r + v4.Family, v6.Family = IPv4, IPv6 + return []*Rule{&v4, &v6} +} + +// cells returns the concrete rules r covers: the cross product of its three merged +// axes, each expanded to the values it spans. A FamilyAny + TCPUDP + DirAny rule +// yields eight cells; a fully concrete rule yields itself. The direction expansion +// role-swaps the outbound half, so each cell is stated in its own natural frame — +// covers compares in the inbound frame, so that swap round-trips. On a backend with +// no output concept (outputHonored false) the direction axis does not distinguish +// two rules, so it is not expanded. +func (r *Rule) cells(outputHonored bool) []*Rule { + dirs := []*Rule{r} + if outputHonored { + dirs = expandDirections(r) + } + var out []*Rule + for _, d := range dirs { + for _, p := range expandProtocols(d) { + out = append(out, expandFamilies(p)...) + } + } + return out +} + +// coveredBy is the coverage relation behind CoveredBy, with the direction axis +// gated on whether the backend distinguishes output rules at all. +func (r *Rule) coveredBy(rules []*Rule, outputHonored bool) bool { + for _, cell := range r.cells(outputHonored) { + covered := false + for _, have := range rules { + if have.covers(cell, outputHonored) { + covered = true + break + } + } + if !covered { + return false + } + } + return true +} + +// CoveredBy reports whether every concrete rule the receiver spans is covered by at +// least one rule in rules. It is the set form of Covers, and its inverse: where +// a.Covers(b) asks whether one rule contains another, b.CoveredBy([]*Rule{a}) asks +// whether a set contains one. +// +// A rule that spans several axes is rarely stored as one object: GetRules reports the +// firewall's actual rows, so a rule the caller authored as FamilyAny may read back as +// an IPv4 row and an IPv6 row on a backend that cannot store one family-agnostic row. +// Such a rule is fully present in the set even though no single member Covers it, so +// coverage is decided cell by cell rather than rule by rule. A caller uses it to +// decide whether a rule is already installed before adding it. +// +// It expands the receiver across family, transport and direction and requires every +// resulting cell to be covered, so a rule spanning both transports is not reported +// present when only its TCP half is. The receiver is not modified. +func (r *Rule) CoveredBy(rules []*Rule) bool { + return r.coveredBy(rules, true) +} + +// splitDualRowProtocol returns the rule a backend must re-add after deleting one +// transport of a genuine TCPUDP row — a single stored rule covering both TCP and +// UDP — to satisfy a concrete-protocol removal: the surviving opposite transport. +// It mirrors splitDualRow for the protocol axis. It returns nil when no split +// applies: the matched row is not a merged TCPUDP row, or the target names no +// single transport (so the whole row goes). +func splitDualRowProtocol(matched, target *Rule) *Rule { + if matched.Proto != TCPUDP { + return nil + } + opp := oppositeProtocol(target.Proto) + if opp == ProtocolAny { + return nil + } + s := *matched + s.Proto = opp + return &s +} + +// splitMergedRow returns the rows a backend must re-add after deleting a single +// stored row that covered more than the caller targeted. A row may be merged on two +// axes at once — nftables' inet table holds a FamilyAny rule as one unpinned row, +// and a TCPUDP rule as one `meta l4proto { tcp, udp }` row — so removing one cell of +// that family×transport grid can leave a remainder that needs two rows to express. +// It composes the per-axis splits: the untargeted family keeps the row's full +// transport coverage, and the untargeted transport is then scoped to the family the +// target named, so the two rows never overlap. It returns nil when the target covers +// the whole row. +func splitMergedRow(matched, target *Rule) []*Rule { + var out []*Rule + if s := splitDualRow(matched, target); s != nil { + out = append(out, s) + } + if s := splitDualRowProtocol(matched, target); s != nil { + // A family split above already re-added the untargeted family across both + // transports, so this row must not repeat it: pin it to the targeted family. + if len(out) > 0 { + s.Family = target.impliedFamily() + } + out = append(out, s) + } + return out +} + +// coversProtocol reports whether an existing rule's protocol have already covers a +// caller rule's protocol want (the asymmetric add/dedup form). TCPUDP spans TCP and +// UDP, so it covers either; every other protocol covers only itself. ProtocolAny is +// not a merged value — it matches every IP protocol — so it covers only ProtocolAny. +func coversProtocol(have, want Protocol) bool { + if have == want { + return true + } + return have == TCPUDP && (want == TCP || want == UDP) +} + +// coversProtocolRemoval reports whether two rules touch a common transport (the +// symmetric remove/move form): a TCPUDP on either side spans TCP and UDP, so it +// touches either concrete transport and vice versa. +func coversProtocolRemoval(a, b Protocol) bool { + if a == b { + return true + } + if a == TCPUDP && (b == TCP || b == UDP) { + return true + } + if b == TCPUDP && (a == TCP || a == UDP) { + return true + } + return false +} + +// protoNeutralized returns a copy of r with its protocol cleared to TCPUDP when it +// sits on the merged tcp/udp axis, so the field compare in EqualForDedup and +// EqualForRemoval does not re-test a protocol coversProtocol has already gated. +// Every other protocol is returned unchanged, keeping matchFields' exact protocol +// equality for rules that never merge. +func (r *Rule) protoNeutralized() *Rule { + if !onProtocolAxis(r.Proto) { + return r + } + c := *r + c.Proto = TCPUDP + return &c +} + +// onProtocolAxis reports whether a protocol participates in the tcp/udp merge: +// the two concrete transports and their merged TCPUDP form. +func onProtocolAxis(p Protocol) bool { + return p == TCP || p == UDP || p == TCPUDP +} + // splitDualRowDirection returns the rule a backend must re-add after deleting one // direction of a genuine DirAny row — a single stored object covering both the // input and output directions — to satisfy a concrete-direction removal: the @@ -1323,193 +1560,30 @@ func (r *Rule) checkICMPType() error { return nil } -// familyMergePairs computes the cross-family pairing shared by every merge -// helper below (filter and NAT, both the collapse and the anchor-index forms), so -// the pairing rule lives in exactly one place. For n rows it scans in order and, -// for each concrete-family row not already absorbed, finds the first later -// opposite-family row whose base is equal and marks it absorbed — each anchor -// absorbs at most one twin. family reports a row's IP family; equalBase reports -// whether rows i and j are equal ignoring family. It returns, per index, whether -// that row was absorbed into an earlier anchor, and per anchor the index of the -// twin it absorbed (-1 if none). It mutates nothing. -func familyMergePairs(n int, family func(int) Family, equalBase func(i, j int) bool) (absorbed []bool, twin []int) { - absorbed = make([]bool, n) - twin = make([]int, n) - for i := range twin { - twin[i] = -1 +// CheckExpandedProtocol reports a TCPUDP rule reaching a row-level marshaller. +// TCPUDP is a merged, logical protocol: a backend with no both-transports form fans +// it into a tcp row and a udp row with expandProtocols before marshalling, so a +// TCPUDP rule arriving here means that fan-out was skipped. Backends whose native +// syntax does carry both transports in one row (nftables' `meta l4proto { tcp, udp }`) +// do not call it. +func (r *Rule) CheckExpandedProtocol() error { + if r.Proto == TCPUDP { + return fmt.Errorf("the tcpudp protocol matches two transports and must be expanded to a tcp and a udp rule") } - for i := 0; i < n; i++ { - if absorbed[i] || family(i) == FamilyAny { - continue - } - for j := i + 1; j < n; j++ { - if absorbed[j] || family(j) == FamilyAny || family(i) == family(j) { - continue - } - if equalBase(i, j) { - absorbed[j] = true - twin[i] = j - break - } - } - } - return absorbed, twin + return nil } -// mergeFamilies collapses pairs of otherwise-identical rules that differ only in -// IP family (one IPv4, one IPv6) into a single FamilyAny rule. Rules that are -// already FamilyAny, or two rules of the same family, are left untouched so that -// a duplicate within a single family is never mistaken for cross-family -// coverage. The surviving anchor of each merged pair is flipped to FamilyAny. -func mergeFamilies(rules []*Rule) []*Rule { - absorbed, twin := familyMergePairs(len(rules), - func(i int) Family { return rules[i].Family }, - func(i, j int) bool { return rules[i].EqualBase(rules[j], true) }) - out := make([]*Rule, 0, len(rules)) - for i, r := range rules { - if absorbed[i] { - continue - } - if twin[i] >= 0 { - r.Family = FamilyAny - } - out = append(out, r) - } - return out -} - -// mergeFamiliesCopy is mergeFamilies over value-copies of the input rules, so the -// caller's rules (and their Family fields) are left untouched. Sync uses it to -// canonicalize a desired set the same way GetRules canonicalizes existing rules, -// without mutating the caller's slice. -func mergeFamiliesCopy(rules []*Rule) []*Rule { - cp := make([]*Rule, len(rules)) - for i, r := range rules { - rc := *r - cp[i] = &rc - } - return mergeFamilies(cp) -} - -// directionMergePairs computes the input/output pairing for the direction merge, -// mirroring familyMergePairs on the direction axis. For n rows it scans in order -// and, for each concrete-direction (input or output) row not already absorbed, -// finds the first later row of the opposite direction that is equal once both are -// put in the inbound frame and marks it absorbed — each anchor absorbs at most one -// twin. Forward and already-merged DirAny rows never pair. dir reports a row's -// direction; equalSwapped reports whether rows i and j are equal in the inbound -// frame. It returns, per index, whether that row was absorbed into an earlier -// anchor, and per anchor the index of the twin it absorbed (-1 if none). -func directionMergePairs(n int, dir func(int) Direction, equalSwapped func(i, j int) bool) (absorbed []bool, twin []int) { - absorbed = make([]bool, n) - twin = make([]int, n) - for i := range twin { - twin[i] = -1 - } - for i := 0; i < n; i++ { - if absorbed[i] || (dir(i) != DirInput && dir(i) != DirOutput) { - continue - } - for j := i + 1; j < n; j++ { - if absorbed[j] || dir(j) != oppositeDirection(dir(i)) { - continue - } - if equalSwapped(i, j) { - absorbed[j] = true - twin[i] = j - break - } - } - } - return absorbed, twin -} - -// mergeDirections collapses pairs of otherwise-identical rules that differ only in -// traffic direction — an input rule and its role-swapped output twin — into a -// single DirAny rule. It is the direction analog of mergeFamilies and is applied -// after it in GetRules, so a rule present as {v4-in, v6-in, v4-out, v6-out} first -// collapses per family to one input and one output row, then merges here to one -// FamilyAny+DirAny rule. Pairing compares in the inbound frame (canonicalMatch) and -// honors family through Equal, so an input rule never merges with an opposite- -// family output rule. The surviving anchor is normalized to the inbound frame and -// flipped to DirAny. Forward rules never merge. -func mergeDirections(rules []*Rule) []*Rule { - absorbed, twin := directionMergePairs(len(rules), - func(i int) Direction { return rules[i].Direction }, - func(i, j int) bool { - return rules[i].canonicalMatch().Equal(rules[j].canonicalMatch(), false) - }) - out := make([]*Rule, 0, len(rules)) - for i, r := range rules { - if absorbed[i] { - continue - } - if twin[i] >= 0 { - // Present the merged rule in the inbound frame; an output anchor is - // role-swapped so DirAny{Source:X} reads back consistently. - if r.Direction == DirOutput { - r = r.directionSwapped() - } - r.Direction = DirAny - } - out = append(out, r) - } - return out -} - -// mergeDirectionsCopy is mergeDirections over value-copies of the input rules, so -// the caller's rules (and their Direction fields) are left untouched. Sync uses it -// alongside mergeFamiliesCopy to canonicalize a desired set the same way GetRules -// canonicalizes existing rules, without mutating the caller's slice. -func mergeDirectionsCopy(rules []*Rule) []*Rule { - cp := make([]*Rule, len(rules)) - for i, r := range rules { - rc := *r - cp[i] = &rc - } - return mergeDirections(cp) -} - -// mergedFamilyAnchors returns the indices of the rules that survive mergeFamilies: -// the anchor (kept) row of each logical rule, in order. mergeFamilies collapses an -// otherwise-identical IPv4/IPv6 pair into the earlier of the two and drops the -// later, so a rule's merged 1-based Number is its position in this anchor sequence. -// A backend whose read merges families but whose chain edits address physical rows -// (nftables) uses this to map a caller-supplied Number/position back to a physical -// chain index. It shares familyMergePairs with mergeFamilies, so the pairing can -// never drift; unlike mergeFamilies it leaves the rules' Family fields untouched, -// since callers pass a physical list they only want to index into. When no pair -// merges, every index is its own anchor. -func mergedFamilyAnchors(rules []*Rule) []int { - absorbed, _ := familyMergePairs(len(rules), - func(i int) Family { return rules[i].Family }, - func(i, j int) bool { return rules[i].EqualBase(rules[j], true) }) - return survivingAnchors(absorbed) -} - -// survivingAnchors returns the indices not marked absorbed, in order — the anchor -// row of each merged pair. Shared by the filter and NAT anchor helpers. -func survivingAnchors(absorbed []bool) []int { - anchors := make([]int, 0, len(absorbed)) - for i, gone := range absorbed { - if !gone { - anchors = append(anchors, i) - } - } - return anchors -} - -// mergedInsertIndex maps a 1-based merged position (a rule's Number, as GetRules -// reports it) to the 0-based physical index to insert before, given the merged -// anchors of the physical list and its row count. A position past the last logical -// rule appends (returns physicalLen). When no IPv4/IPv6 pair has merged, every row -// is its own anchor and this reduces to position-1 — the plain physical index — so -// the common single-representation case is unaffected. It exists because the read -// path merges v4/v6 pairs (so Number counts logical rules) while physical edits act -// on physical rows; without this mapping an insert lands too early by the number of -// merged pairs preceding it. Backends that edit a physical, merge-collapsed list -// (nftables chains, pf's anchor) share it. -func mergedInsertIndex(anchors []int, physicalLen, position int) int { +// logicalInsertIndex maps a 1-based logical position (a rule's Number, as GetRules +// reports it) to the 0-based physical index to insert before, given the physical +// index of each logical rule and the physical row count. A position past the last +// logical rule appends (returns physicalLen). It exists because a backend's physical +// list may hold rows GetRules does not report as their own rule — an unmodeled +// foreign line pf keeps as an opaque row, a route tuple ufw numbers but this backend +// cannot model, the LOG line iptables folds into the action line that follows it — +// so a logical position counted over the reported rules lands at the wrong physical +// row unless it is mapped through the anchors. When every physical row is its own +// logical rule this reduces to position-1, the plain physical index. +func logicalInsertIndex(anchors []int, physicalLen, position int) int { if position < 1 { position = 1 } @@ -1524,10 +1598,8 @@ func mergedInsertIndex(anchors []int, physicalLen, position int) int { // forward chains are ordered independently (iptables, nftables) number rules // this way so a rule's Number matches the InsertRule/MoveRule position for its // chain. A DirAny rule counts in the input bucket — its Number reflects the input -// chain, as a FamilyAny rule's Number reflects the IPv4 chain. It must run before -// mergeDirections so the surviving pure-output rows keep the physical output -// position of their still-present twin. It is derived on read and, like HasPrefix, -// ignored on add and not part of rule identity. +// chain, as a FamilyAny rule's Number reflects the IPv4 chain. It is derived on +// read and, like HasPrefix, ignored on add and not part of rule identity. func numberByDirection(rules []*Rule) { var in, out, fwd int for _, r := range rules { @@ -1718,6 +1790,14 @@ func (r *NATRule) validate() error { default: return fmt.Errorf("invalid nat kind") } + // TCPUDP is a multi-state, logical protocol with no NAT form: a translation is applied + // per transport, and no backend's NAT syntax carries both in one rule. The filter + // path fans TCPUDP out with expandProtocols; NAT has no such fan-out, so reject it + // here rather than let a backend emit a `tcpudp` protocol token. A caller wanting + // both transports translated adds a tcp rule and a udp rule. + if r.Proto == TCPUDP { + return fmt.Errorf("nat rules take a single transport; add a tcp rule and a udp rule: %w", ErrUnsupportedNAT) + } if portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) { return fmt.Errorf("a port requires a tcp or udp protocol") } @@ -1761,8 +1841,46 @@ func (r *NATRule) Equal(o *NATRule) bool { // EqualForDedup is the NAT-rule add guard mirroring Rule.EqualForDedup: the same // base translation, and the receiver's family covers o's. func (r *NATRule) EqualForDedup(o *NATRule) bool { - fe, fr := r.impliedFamily(), o.impliedFamily() - return (fe == FamilyAny || fe == fr) && r.EqualBase(o) + return r.Covers(o) +} + +// Covers reports whether the receiver's coverage contains o's, mirroring Rule.Covers +// for NAT rules. Family is the only axis a NAT rule spans — a translation applies per +// transport and NAT has no direction — so a FamilyAny rule covers either family and a +// concrete one covers only itself. +func (r *NATRule) Covers(o *NATRule) bool { + return coversFamily(r.impliedFamily(), o.impliedFamily()) && r.EqualBase(o) +} + +// CoveredBy reports whether every concrete NAT rule the receiver spans is covered by +// at least one rule in rules, mirroring Rule.CoveredBy. A FamilyAny receiver requires +// both families to be covered, whether by one FamilyAny rule or by an IPv4 rule and +// an IPv6 rule. +func (r *NATRule) CoveredBy(rules []*NATRule) bool { + for _, cell := range r.cells() { + covered := false + for _, have := range rules { + if have.Covers(cell) { + covered = true + break + } + } + if !covered { + return false + } + } + return true +} + +// cells returns the concrete-family NAT rules the receiver spans, the NAT analog of +// Rule.cells over the single axis a NAT rule spans. +func (r *NATRule) cells() []*NATRule { + if r.impliedFamily() != FamilyAny { + return []*NATRule{r} + } + v4, v6 := *r, *r + v4.Family, v6.Family = IPv4, IPv6 + return []*NATRule{&v4, &v6} } // EqualForRemoval is the NAT-rule remove guard mirroring Rule.EqualForRemoval: @@ -1772,36 +1890,6 @@ func (r *NATRule) EqualForRemoval(o *NATRule) bool { return (ft == FamilyAny || fr == FamilyAny || ft == fr) && r.EqualBase(o) } -// mergeNATFamilies collapses IPv4/IPv6 pairs of otherwise-identical NAT rules -// into a single FamilyAny rule when reading rules back, mirroring mergeFamilies -// for filter rules. -func mergeNATFamilies(rules []*NATRule) []*NATRule { - absorbed, twin := familyMergePairs(len(rules), - func(i int) Family { return rules[i].Family }, - func(i, j int) bool { return rules[i].EqualBase(rules[j]) }) - out := make([]*NATRule, 0, len(rules)) - for i, r := range rules { - if absorbed[i] { - continue - } - if twin[i] >= 0 { - r.Family = FamilyAny - } - out = append(out, r) - } - return out -} - -// mergedNATFamilyAnchors returns the indices of the NAT rules that survive -// mergeNATFamilies, mirroring mergedFamilyAnchors for NAT rules so a merged NAT -// rule's Number maps back to a physical nat-chain index for InsertNATRule. -func mergedNATFamilyAnchors(rules []*NATRule) []int { - absorbed, _ := familyMergePairs(len(rules), - func(i int) Family { return rules[i].Family }, - func(i, j int) bool { return rules[i].EqualBase(rules[j]) }) - return survivingAnchors(absorbed) -} - // numberNATByChain assigns each NAT rule a 1-based Number within its nat chain — // prerouting for destination NAT, postrouting for source NAT — matching the // InsertNATRule position on chain-ordered backends (iptables, nftables). @@ -1859,11 +1947,11 @@ const ( // DirForward is the routing (forward) direction, where a backend models it. DirForward // DirAny applies to both the input and output directions. It is the direction - // analog of FamilyAny: a read-side merge collapses an input rule and its - // role-swapped output twin into one DirAny rule, and a write-side fan-out - // expands a DirAny rule back into a concrete input row plus a swapped output - // row. It never covers DirForward (a routed rule has no input/output twin) and - // must be declared last so DirInput stays the zero value. + // analog of FamilyAny: a backend that can store a bidirectional rule as one + // object reads it back as DirAny, while one that cannot fans it into a concrete + // input row plus a role-swapped output row on write (expandDirections). It never + // covers DirForward (a routed rule has no input/output twin) and must be declared + // last so DirInput stays the zero value. DirAny ) @@ -2003,6 +2091,22 @@ type Capabilities struct { Comments bool } +// The backend type strings Manager.Type reports, one per backend. They are declared +// here beside the interface rather than beside each implementation because every +// backend lives behind a build tag for its own platform: a caller — or a test — that +// branches on mgr.Type() must be able to name any backend on any platform, not only +// the ones that compile for the host. +const ( + IPTablesType = "iptables" + NFTType = "nftables" + UFWType = "ufw" + FirewallDType = "firewalld" + CSFType = "csf" + APFType = "apf" + PFType = "pf" + WFType = "windows-firewall" +) + // Manager is the standard firewall manager interface. // // Every method that performs I/O (shelling out, D-Bus, or the Windows API) @@ -2166,17 +2270,26 @@ func ReplaceRules(ctx context.Context, mgr Manager, zoneName string, rules []*Ru } // Sync reconciles the zone's filter rules toward desired: it removes any rule -// the backend reports that is not in desired and adds desired rules that are not -// yet present, leaving rules already in place untouched. It reconciles the +// the backend reports that desired does not cover and adds desired rules that are +// not yet present, leaving rules already in place untouched. It reconciles the // actual firewall state and does not filter on HasPrefix — a rule without the // configured prefix (HasPrefix=false) is reconciled like any other, so a foreign // rule not in desired is removed. A rule unchanged between existing and desired is // never removed and re-added, but removal still runs as its own pass before // additions, so a desired set that shares nothing with the existing rules is not -// applied atomically. Sync reports how many rules were added and removed. Rule -// identity is compared with Rule.Equal honoring -// the backend's Output capability, so the Comment, HasPrefix and Packets/Bytes -// fields do not affect the diff. +// applied atomically. Sync reports how many rules were added and removed. +// +// The diff is a coverage relation, not rule-for-rule equality, because GetRules +// reports the firewall's actual rows and a backend stores a rule the way its model +// allows: iptables holds a FamilyAny rule as an IPv4 row plus an IPv6 row, pf holds +// a DirAny rule as an inbound row plus an outbound row, while nftables holds either +// as one row. An existing rule is kept when every concrete cell it spans is wanted +// (Rule.CoveredBy over desired), and a desired rule is added when some cell it spans +// is not yet present (its CoveredBy over existing). Comparing this way keeps Sync a +// no-op against its own output whichever representation the backend chose, where +// plain equality would remove-and-re-add every fanned-out rule on each run. A rule +// only partially covered by desired is removed whole and the wanted part re-added. +// The Comment, HasPrefix and Packets/Bytes fields never affect the diff. func Sync(ctx context.Context, mgr Manager, zoneName string, desired []*Rule) (added, removed int, err error) { existing, err := mgr.GetRules(ctx, zoneName) if err != nil { @@ -2184,63 +2297,34 @@ func Sync(ctx context.Context, mgr Manager, zoneName string, desired []*Rule) (a } outputHonored := mgr.Capabilities().Output - // Canonicalize desired the same way GetRules canonicalizes existing: collapse an - // otherwise-identical IPv4/IPv6 pair into one FamilyAny rule, then collapse an - // input rule and its role-swapped output twin into one DirAny rule. GetRules - // merges such pairs on read, so a caller that lists the families or directions - // separately would never match the single merged rule and Sync would - // remove-and-re-add it on every run (a transient gap). Copy first — the merges - // mutate their input's fields and slice. Family before direction, matching the - // order in every backend's GetRules. - desired = mergeFamiliesCopy(desired) - desired = mergeDirectionsCopy(desired) - - // Remove any existing rule that is not wanted. Sync reconciles the actual - // firewall state toward desired, so any rule the backend reports and can act on - // is fair game; backends whose mutations are scoped to a private table/anchor - // simply no-op on rules outside it. + // Remove any existing rule desired does not fully cover. Sync reconciles the + // actual firewall state toward desired, so any rule the backend reports and can + // act on is fair game; backends whose mutations are scoped to a private + // table/anchor simply no-op on rules outside it. A rule whose cells are spread + // across several desired rules is still fully wanted and is kept. + kept := make([]*Rule, 0, len(existing)) for _, e := range existing { - keep := false - for _, d := range desired { - if e.Equal(d, outputHonored) { - keep = true - break - } + if e.coveredBy(desired, outputHonored) { + kept = append(kept, e) + continue } - if !keep { - if err := mgr.RemoveRule(ctx, zoneName, e); err != nil { - return added, removed, err - } - removed++ + if err := mgr.RemoveRule(ctx, zoneName, e); err != nil { + return added, removed, err } + removed++ } - // Add any wanted rule that is not already present. + // Add any wanted rule that is not already present. A rule already in the + // firewall — whoever created it — counts as present, so Sync does not add a + // duplicate of a rule the surviving rows already cover. Queued additions count + // as present too: adding a covered duplicate would double-apply it on a + // RuleBatcher backend (which sees both as new) and over-count added on the rest. var toAdd []*Rule for _, d := range desired { - present := false - for _, e := range existing { - // A rule already in the firewall — whoever created it — counts as present, - // so Sync does not add a duplicate of an identical existing rule. - if e.Equal(d, outputHonored) { - present = true - break - } - } - // Skip a duplicate within desired itself: if an equal rule is already - // queued, adding it again would double-apply it on a RuleBatcher backend - // (which sees both as new) and over-count added on the others. - if !present { - for _, a := range toAdd { - if a.Equal(d, outputHonored) { - present = true - break - } - } - } - if !present { - toAdd = append(toAdd, d) + if d.coveredBy(kept, outputHonored) || d.coveredBy(toAdd, outputHonored) { + continue } + toAdd = append(toAdd, d) } if err := AddRules(ctx, mgr, zoneName, toAdd); err != nil { return added, removed, err diff --git a/firewall_test.go b/firewall_test.go index b80ce53..4ee7b09 100644 --- a/firewall_test.go +++ b/firewall_test.go @@ -112,34 +112,48 @@ func (m *syncDirRecorder) RemoveRule(_ context.Context, _ string, r *Rule) error return nil } -// Sync must not churn a DirAny rule: whether the desired set lists it as one DirAny -// rule or as the two separate directions, mergeDirectionsCopy canonicalizes it to -// match the merged existing rule, so nothing is added or removed. +// Sync must not churn a DirAny rule, whichever representation the backend chose: one +// that stores it as a single bidirectional object reports one DirAny rule, while one +// that fans it out reports an input rule plus a role-swapped output rule. Each is +// fully covered by the other, so nothing is added or removed either way. func TestSyncDirAnyIdempotent(t *testing.T) { - existing := func() []*Rule { + dirAny := func() []*Rule { return []*Rule{{Direction: DirAny, Source: "1.2.3.4", Action: Accept}} } + fannedOut := func() []*Rule { + return []*Rule{ + {Direction: DirInput, Source: "1.2.3.4", Action: Accept}, + {Direction: DirOutput, Destination: "1.2.3.4", Action: Accept}, + } + } - // Desired lists the single DirAny rule. - m := &syncDirRecorder{existing: existing()} - added, removed, err := Sync(context.Background(), m, "", []*Rule{{Direction: DirAny, Source: "1.2.3.4", Action: Accept}}) + // The backend stored it as one DirAny row; desired names it the same way. + m := &syncDirRecorder{existing: dirAny()} + added, removed, err := Sync(context.Background(), m, "", dirAny()) require.NoError(t, err) require.Zero(t, added) require.Zero(t, removed) require.Empty(t, m.added) require.Empty(t, m.removed) - // Desired lists the two directions separately; they collapse to DirAny and match. - m2 := &syncDirRecorder{existing: existing()} - added, removed, err = Sync(context.Background(), m2, "", []*Rule{ - {Direction: DirInput, Source: "1.2.3.4", Action: Accept}, - {Direction: DirOutput, Destination: "1.2.3.4", Action: Accept}, - }) + // The backend fanned it out into two concrete rows; desired still names one DirAny + // rule, whose cells those two rows cover between them. + m2 := &syncDirRecorder{existing: fannedOut()} + added, removed, err = Sync(context.Background(), m2, "", dirAny()) require.NoError(t, err) - require.Zero(t, added, "a DirInput+DirOutput desired pair collapses to DirAny and must not churn") + require.Zero(t, added, "a fanned-out DirAny rule is covered by the DirAny desired rule") require.Zero(t, removed) require.Empty(t, m2.added) require.Empty(t, m2.removed) + + // Desired lists the two directions separately against a single stored DirAny row. + m3 := &syncDirRecorder{existing: dirAny()} + added, removed, err = Sync(context.Background(), m3, "", fannedOut()) + require.NoError(t, err) + require.Zero(t, added, "the DirAny row covers each concrete desired direction") + require.Zero(t, removed, "and the two desired rules cover the DirAny row between them") + require.Empty(t, m3.added) + require.Empty(t, m3.removed) } func TestPortNeedsConcreteProtocol(t *testing.T) { @@ -224,43 +238,32 @@ func TestNumberingHelpers(t *testing.T) { require.Equal(t, []int{1, 2, 3}, []int{natSeq[0].Number, natSeq[1].Number, natSeq[2].Number}) } -// A backend that edits a physical, merge-collapsed list (nft chains, pf's anchor, -// ufw's numbered list) must translate a caller's merged position into the physical -// index of that logical rule's anchor row. The naive position-1 lands too early — -// inside a merged IPv4/IPv6 pair — once such a pair precedes the target. -func TestMergedInsertIndex(t *testing.T) { - // Raw (unmerged) list: an ssh IPv4/IPv6 pair that mergeFamilies collapses into - // one logical rule, followed by an IPv4-only https rule. - raw := []*Rule{ - {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, - {Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, - {Family: IPv4, Proto: TCP, Port: 443, Action: Accept}, - } - anchors := mergedFamilyAnchors(raw) - require.Equal(t, []int{0, 2}, anchors, "ssh pair collapses to anchor 0; https is anchor 2") +// A backend whose physical list holds rows GetRules does not report as their own rule +// — pf's opaque foreign lines, ufw's unmodeled route tuples, iptables' LOG line folded +// into the action that follows it — must translate a caller's logical position into +// the physical index of that rule's row. The naive position-1 lands too early once +// such a row precedes the target. +func TestLogicalInsertIndex(t *testing.T) { + // Five physical rows where rows 1 and 3 are unmodeled, so the three reported rules + // live at physical indices 0, 2 and 4. + anchors := []int{0, 2, 4} + const physicalLen = 5 - // Insert before the https rule, which GetRules numbers as merged position 2. - // The physical index must be 2 (before the https row), not the naive 1 (which - // would split the ssh IPv4/IPv6 pair). - require.Equal(t, 2, mergedInsertIndex(anchors, len(raw), 2)) - require.NotEqual(t, 2-1, mergedInsertIndex(anchors, len(raw), 2), - "the merged position must not be used as a raw index") + require.Equal(t, 0, logicalInsertIndex(anchors, physicalLen, 1)) + require.Equal(t, 2, logicalInsertIndex(anchors, physicalLen, 2)) + require.NotEqual(t, 2-1, logicalInsertIndex(anchors, physicalLen, 2), + "a logical position must not be used as a raw index") + require.Equal(t, 4, logicalInsertIndex(anchors, physicalLen, 3)) - // Position 1 prepends (physical index 0); a position past the end appends. - require.Equal(t, 0, mergedInsertIndex(anchors, len(raw), 1)) - require.Equal(t, len(raw), mergedInsertIndex(anchors, len(raw), 3)) - require.Equal(t, len(raw), mergedInsertIndex(anchors, len(raw), 99)) - require.Equal(t, 0, mergedInsertIndex(anchors, len(raw), 0), "a non-positive position prepends") + // A position past the last logical rule appends; a non-positive position prepends. + require.Equal(t, physicalLen, logicalInsertIndex(anchors, physicalLen, 4)) + require.Equal(t, physicalLen, logicalInsertIndex(anchors, physicalLen, 99)) + require.Equal(t, 0, logicalInsertIndex(anchors, physicalLen, 0), "a non-positive position prepends") - // With no merged pair, every row is its own anchor and the mapping is identity. - flat := []*Rule{ - {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, - {Family: IPv4, Proto: TCP, Port: 80, Action: Accept}, - {Family: IPv4, Proto: TCP, Port: 443, Action: Accept}, - } - fa := mergedFamilyAnchors(flat) + // With every row modeled, the mapping is the identity. + flat := []int{0, 1, 2} for pos := 1; pos <= len(flat); pos++ { - require.Equal(t, pos-1, mergedInsertIndex(fa, len(flat), pos)) + require.Equal(t, pos-1, logicalInsertIndex(flat, len(flat), pos)) } } @@ -389,34 +392,27 @@ func TestEqualDistinguishesDirection(t *testing.T) { require.True(t, in.EqualBase(fwd, false)) } -// ErrUnsupportedForward is a member of the ErrUnsupported family so a caller can -// tell a genuinely unexpressible forward rule from a real failure. -func TestErrUnsupportedForward(t *testing.T) { - require.ErrorIs(t, ErrUnsupportedForward, ErrUnsupported) - require.ErrorIs(t, unsupportedForward("test"), ErrUnsupportedForward) - require.ErrorIs(t, unsupportedForward("test"), ErrUnsupported) -} +// A backend that cannot store one family-agnostic row reports a FamilyAny rule as an +// IPv4 row plus an IPv6 row. The pair must cover the rule the caller authored, and a +// same-family duplicate must not — a duplicate within one family is not cross-family +// coverage, and reporting it as such would leave the other family unprotected. +func TestFamilyCoverage(t *testing.T) { + want := &Rule{Family: FamilyAny, Port: 80, Proto: TCP, Action: Accept} -func TestMergeFamilies(t *testing.T) { - // A genuine IPv4/IPv6 pair that is otherwise identical collapses to one - // FamilyAny rule. - merged := mergeFamilies([]*Rule{ + require.True(t, want.CoveredBy([]*Rule{ {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, {Family: IPv6, Port: 80, Proto: TCP, Action: Accept}, - }) - require.Len(t, merged, 1, "expected a v4/v6 pair to merge into 1 rule") - require.Equal(t, FamilyAny, merged[0].Family, "expected merged rule to be FamilyAny") + }), "a v4/v6 pair covers the FamilyAny rule it materializes") - // Two identical same-family rules must NOT be collapsed into FamilyAny; a - // duplicate within a single family is not cross-family coverage. - merged = mergeFamilies([]*Rule{ + require.False(t, want.CoveredBy([]*Rule{ {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, - }) - for _, r := range merged { - require.NotEqual(t, FamilyAny, r.Family, - "same-family duplicate was wrongly merged into FamilyAny: %+v", merged) - } + }), "a same-family duplicate must not be mistaken for cross-family coverage") + + // A single family-agnostic row covers both halves on its own. + require.True(t, want.CoveredBy([]*Rule{ + {Family: FamilyAny, Port: 80, Proto: TCP, Action: Accept}, + })) } // DirInput must remain the zero value and "any"/"both" must parse to DirAny so a @@ -492,71 +488,62 @@ func TestCoversDirection(t *testing.T) { require.True(t, coversDirectionRemoval(DirForward, DirForward, true)) } -// mergeDirections collapses an input rule and its role-swapped output twin into one -// DirAny rule, honoring family and never merging a forward rule. -func TestMergeDirections(t *testing.T) { - // A csf-style host allow: inbound Source=X + outbound Destination=X collapse to - // one DirAny rule in the inbound frame (Source=X). - merged := mergeDirections([]*Rule{ +// A backend that cannot store a bidirectional rule reports a DirAny rule as an input +// row plus its role-swapped output row. Coverage honors that swap, honors family, and +// never accepts a forward rule as one half of the pair. +func TestDirectionCoverage(t *testing.T) { + // A csf-style host allow: inbound Source=X plus outbound Destination=X cover the + // DirAny rule, which is authored in the inbound frame (Source=X). + hostAllow := &Rule{Direction: DirAny, Family: IPv4, Source: "1.2.3.4", Action: Accept} + require.True(t, hostAllow.CoveredBy([]*Rule{ {Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Action: Accept}, {Direction: DirOutput, Family: IPv4, Destination: "1.2.3.4", Action: Accept}, - }) - require.Len(t, merged, 1, "in+out host allow must merge to 1 rule") - require.Equal(t, DirAny, merged[0].Direction) - require.Equal(t, "1.2.3.4", merged[0].Source) - require.Empty(t, merged[0].Destination) + }), "in+out host allow covers the DirAny rule") // A ported service both ways: dport 22 inbound pairs with sport 22 outbound. - merged = mergeDirections([]*Rule{ + svc := &Rule{Direction: DirAny, Proto: TCP, Port: 22, Action: Accept} + require.True(t, svc.CoveredBy([]*Rule{ {Direction: DirInput, Proto: TCP, Port: 22, Action: Accept}, {Direction: DirOutput, Proto: TCP, SourcePort: 22, Action: Accept}, - }) - require.Len(t, merged, 1, "dport-in + sport-out must merge") - require.Equal(t, DirAny, merged[0].Direction) - require.Equal(t, uint16(22), merged[0].Port) + }), "dport-in + sport-out covers the DirAny rule") - // A forward rule must never merge, even if a swap-equal partner exists. - merged = mergeDirections([]*Rule{ - {Direction: DirForward, Proto: TCP, Port: 22, Action: Accept}, - {Direction: DirOutput, Proto: TCP, SourcePort: 22, Action: Accept}, - }) - require.Len(t, merged, 2, "forward rules never participate in the direction merge") + // A forward rule is not the outbound half of anything: it has no in/out twin. + require.False(t, svc.CoveredBy([]*Rule{ + {Direction: DirInput, Proto: TCP, Port: 22, Action: Accept}, + {Direction: DirForward, Proto: TCP, SourcePort: 22, Action: Accept}, + }), "a forward rule never covers the outbound half of a DirAny rule") - // A cross-family pair must not merge across direction (v4-in with v6-out). - merged = mergeDirections([]*Rule{ + // An opposite-family outbound row leaves the IPv4 outbound cell uncovered. + v4svc := &Rule{Direction: DirAny, Family: IPv4, Proto: TCP, Port: 22, Action: Accept} + require.False(t, v4svc.CoveredBy([]*Rule{ {Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, {Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept}, - }) - require.Len(t, merged, 2, "an opposite-family in/out pair must not merge") + }), "an opposite-family outbound row must not cover the IPv4 outbound cell") } -// A rule present as {v4-in, v6-in, v4-out, v6-out} collapses, after mergeFamilies -// then mergeDirections, to a single FamilyAny + DirAny rule. -func TestMergeFamiliesThenDirectionsFourRows(t *testing.T) { +// A rule spanning family and direction has four cells, and a backend that can store +// neither axis reports it as {v4-in, v6-in, v4-out, v6-out}. All four together cover +// it; any three do not. +func TestFamilyAndDirectionCoverageFourRows(t *testing.T) { + want := &Rule{Direction: DirAny, Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept} rows := []*Rule{ {Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, {Direction: DirInput, Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, {Direction: DirOutput, Family: IPv4, Proto: TCP, SourcePort: 22, Action: Accept}, {Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept}, } - rows = mergeFamilies(rows) - numberByDirection(rows) - rows = mergeDirections(rows) - require.Len(t, rows, 1, "the 4-cell rule must collapse to one") - require.Equal(t, FamilyAny, rows[0].Family) - require.Equal(t, DirAny, rows[0].Direction) - require.Equal(t, uint16(22), rows[0].Port) + require.True(t, want.CoveredBy(rows), "the four cells cover the rule") + require.False(t, want.CoveredBy(rows[:3]), "dropping the v6 outbound cell breaks coverage") } -// A rule that is symmetric under the swap (Source==Destination, no ports) still -// merges its in/out pair, and the surviving DirAny rule is unchanged by the swap. -func TestMergeDirectionsSymmetric(t *testing.T) { - merged := mergeDirections([]*Rule{ +// A rule symmetric under the direction swap (Source==Destination, no ports) is +// covered by its in/out pair even though the swap is a no-op on it. +func TestDirectionCoverageSymmetric(t *testing.T) { + want := &Rule{Direction: DirAny, Family: IPv4, Source: "1.2.3.4", Destination: "1.2.3.4", Action: Accept} + require.True(t, want.CoveredBy([]*Rule{ {Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Destination: "1.2.3.4", Action: Accept}, {Direction: DirOutput, Family: IPv4, Source: "1.2.3.4", Destination: "1.2.3.4", Action: Accept}, - }) - require.Len(t, merged, 1) - require.Equal(t, DirAny, merged[0].Direction) + })) } // expandDirections fans a DirAny rule into an inbound row plus a swapped outbound @@ -910,29 +897,24 @@ func TestProtocolUnmarshalRejectsUnknown(t *testing.T) { require.Error(t, json.Unmarshal([]byte(`"bogus"`), &p)) } -// TestMergedNATInsertIndexMasqueradePair verifies the merged-position mapping an -// ordered NAT backend (pf, nft) uses for InsertNATRule: GetNATRules merges an -// IPv4/IPv6 masquerade pair into one logical rule, so a caller-supplied position -// must map back to the physical row before which to insert. Without the mapping a -// plain position-1 would splice a new rule into the middle of the merged pair. -func TestMergedNATInsertIndexMasqueradePair(t *testing.T) { - // Physical anchor order: masquerade IPv4, its IPv6 twin, then a DNAT rule. - masqV4 := &NATRule{Kind: Masquerade, Interface: "em0", Family: IPv4} - masqV6 := &NATRule{Kind: Masquerade, Interface: "em0", Family: IPv6} - dnat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", Family: IPv4} - physical := []*NATRule{masqV4, masqV6, dnat} +// A FamilyAny masquerade rule a backend stored as an IPv4 row plus an IPv6 row is +// still fully present, so a caller must not be told to add it again. Coverage decides +// this cell by cell, since no single row Covers the FamilyAny rule. +func TestNATFamilyCoverage(t *testing.T) { + masq := &NATRule{Kind: Masquerade, Interface: "em0", Family: FamilyAny} + require.True(t, masq.CoveredBy([]*NATRule{ + {Kind: Masquerade, Interface: "em0", Family: IPv4}, + {Kind: Masquerade, Interface: "em0", Family: IPv6}, + }), "the v4/v6 pair covers the family-agnostic masquerade rule") - // GetNATRules collapses the masquerade pair, so it reports masq=Number 1, - // dnat=Number 2. Inserting at merged position 2 means "before the DNAT rule". - anchors := mergedNATFamilyAnchors(physical) - require.Equal(t, []int{0, 2}, anchors, "the masquerade twin at index 1 is absorbed") + require.False(t, masq.CoveredBy([]*NATRule{ + {Kind: Masquerade, Interface: "em0", Family: IPv4}, + }), "the IPv4 row alone leaves the IPv6 cell uncovered") - idx := mergedInsertIndex(anchors, len(physical), 2) - require.Equal(t, 2, idx, "position 2 must map before the DNAT row (physical index 2), not split the masq pair") - - // Position 1 maps before the masquerade anchor; a position past the end appends. - require.Equal(t, 0, mergedInsertIndex(anchors, len(physical), 1)) - require.Equal(t, len(physical), mergedInsertIndex(anchors, len(physical), 3)) + // A single family-agnostic row covers both halves on its own. + require.True(t, masq.CoveredBy([]*NATRule{ + {Kind: Masquerade, Interface: "em0", Family: FamilyAny}, + })) } // TestEqualForDedupFamily locks the add-time dedup family-cover relation: an @@ -975,24 +957,57 @@ func (m *syncStateMock) RemoveRule(_ context.Context, _ string, r *Rule) error { return nil } -// Sync must not churn when GetRules has merged an IPv4/IPv6 pair into one -// FamilyAny rule but the caller lists the two families separately: the merged -// existing rule already covers both desired twins, so nothing is added or removed. -func TestSyncMergedFamilyNoChurn(t *testing.T) { - merged := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop} - m := &syncStateMock{existing: []*Rule{merged}} +// Sync must not churn on the family axis whichever side is the merged one: a stored +// family-agnostic row covers a caller listing the two families separately, and a pair +// of stored family-pinned rows covers a caller naming one FamilyAny rule. The second +// case is the one every fan-out backend hits against its own output. +func TestSyncFamilyNoChurn(t *testing.T) { + // The backend stored one family-agnostic row; desired lists both families. + m := &syncStateMock{existing: []*Rule{{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop}}} desired := []*Rule{ {Family: IPv4, Proto: TCP, Port: 22, Action: Drop}, {Family: IPv6, Proto: TCP, Port: 22, Action: Drop}, } added, removed, err := Sync(context.Background(), m, "", desired) require.NoError(t, err) - require.Equal(t, 0, removed, "the merged existing rule covers both twins; nothing removed") - require.Equal(t, 0, added, "the merged existing rule covers both twins; nothing added") + require.Equal(t, 0, removed, "the family-agnostic row covers both desired twins; nothing removed") + require.Equal(t, 0, added, "the family-agnostic row covers both desired twins; nothing added") require.Empty(t, m.removed) require.Empty(t, m.added) require.NotNil(t, desired[0], "Sync must not mutate the caller's desired slice entries") require.Equal(t, IPv4, desired[0].Family, "Sync must not mutate the caller's desired rules") + + // The backend fanned the rule out into a v4 row and a v6 row; desired names one + // FamilyAny rule. Neither row Covers it alone, but together they do. + m2 := &syncStateMock{existing: []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Drop}, + {Family: IPv6, Proto: TCP, Port: 22, Action: Drop}, + }} + added, removed, err = Sync(context.Background(), m2, "", []*Rule{ + {Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop}, + }) + require.NoError(t, err) + require.Equal(t, 0, removed, "each stored row is covered by the FamilyAny desired rule") + require.Equal(t, 0, added, "the two stored rows cover the FamilyAny desired rule between them") + require.Empty(t, m2.removed) + require.Empty(t, m2.added) +} + +// Sync removes an existing rule that desired only partially covers: the whole row +// goes, and the wanted part is added back. Here the backend holds one FamilyAny row +// but only IPv4 is wanted. +func TestSyncPartiallyCoveredRuleIsReplaced(t *testing.T) { + m := &syncStateMock{existing: []*Rule{{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop}}} + added, removed, err := Sync(context.Background(), m, "", []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Drop}, + }) + require.NoError(t, err) + require.Equal(t, 1, removed, "the FamilyAny row covers an unwanted IPv6 cell, so it is removed") + require.Equal(t, 1, added, "the wanted IPv4 rule is added back") + require.Len(t, m.removed, 1) + require.Equal(t, FamilyAny, m.removed[0].Family) + require.Len(t, m.added, 1) + require.Equal(t, IPv4, m.added[0].Family) } // TestEqualForRemovalFamily: a FamilyAny target (a merged rule) matches every @@ -1008,21 +1023,17 @@ func TestEqualForRemovalFamily(t *testing.T) { require.False(t, base(IPv4).EqualForRemoval(base(IPv6), true), "an IPv6 target must not touch an IPv4 row") } -// The matcher pf and nft RemoveRule use — EqualForRemoval — must find a merged -// FamilyAny rule against both concrete physical rows, and a concrete-family -// target must match only its own row. -func TestMergedFamilyMatcherFindsBothRows(t *testing.T) { +// The matcher pf and nft RemoveRule use — EqualForRemoval — must match a FamilyAny +// target against both concrete physical rows, so removing it clears every row it +// covers, while a concrete-family target matches only its own row. +func TestFamilyAnyRemovalMatchesBothRows(t *testing.T) { v4 := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept} v6 := &Rule{Family: IPv6, Proto: TCP, Port: 22, Action: Accept} - merged := mergeFamilies([]*Rule{ - {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, - {Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, - }) - require.Len(t, merged, 1) - require.Equal(t, FamilyAny, merged[0].impliedFamily()) - // A merged (FamilyAny) target matches both rows. - require.True(t, v4.EqualForRemoval(merged[0], true)) - require.True(t, v6.EqualForRemoval(merged[0], true)) + any := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept} + + // A FamilyAny target matches both rows. + require.True(t, v4.EqualForRemoval(any, true)) + require.True(t, v6.EqualForRemoval(any, true)) // A concrete IPv4 target matches only the IPv4 row. require.True(t, v4.EqualForRemoval(v4, true)) require.False(t, v6.EqualForRemoval(v4, true)) @@ -1146,20 +1157,27 @@ func TestParseRateUnit(t *testing.T) { require.Error(t, err) } -func TestRateLimitString(t *testing.T) { - require.Equal(t, "10/minute", (&RateLimit{Rate: 10, Unit: PerMinute}).String()) - require.Equal(t, "5/second", (&RateLimit{Rate: 5, Unit: PerSecond}).String()) -} - -func TestNATRuleEqualAndMerge(t *testing.T) { +func TestNATRuleEqualAndCovers(t *testing.T) { a := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} b := &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} require.True(t, a.EqualBase(b), "same match, different family") require.False(t, a.Equal(b), "Equal must honor family") - merged := mergeNATFamilies([]*NATRule{a, b}) - require.Len(t, merged, 1, "an ipv4/ipv6 pair should merge") - require.Equal(t, FamilyAny, merged[0].Family) + // A DNAT to an IPv4 address is an IPv4 rule whatever its Family field says, so + // coverage is decided on the implied family. Use a redirect, which names no + // address, to exercise the family-agnostic case. + anyRedir := &NATRule{Kind: Redirect, Family: FamilyAny, Proto: TCP, Port: 80, ToPort: 8080} + v4Redir := &NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080} + v6Redir := &NATRule{Kind: Redirect, Family: IPv6, Proto: TCP, Port: 80, ToPort: 8080} + require.True(t, anyRedir.Covers(v4Redir), "a family-agnostic rule covers either family") + require.True(t, anyRedir.Covers(v6Redir)) + require.False(t, v4Redir.Covers(anyRedir), "coverage is asymmetric") + require.False(t, v4Redir.Covers(v6Redir), "a concrete family covers only itself") + + // The address pins the family, so the FamilyAny DNAT covers only its IPv4 twin. + anyDNAT := &NATRule{Kind: DNAT, Family: FamilyAny, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} + require.True(t, anyDNAT.Covers(a)) + require.False(t, anyDNAT.Covers(b), "an IPv4 translation target makes the rule IPv4") } func TestRuleLogLimitIdentity(t *testing.T) { @@ -1206,65 +1224,34 @@ func TestCommentNotPartOfIdentity(t *testing.T) { require.True(t, a.EqualBase(c, true)) } -// Direction/SetType string rendering is stable for the rule encoders. -func TestDirectionAndSetType(t *testing.T) { - require.Equal(t, "input", DirInput.String()) - require.Equal(t, "output", DirOutput.String()) - require.Equal(t, "forward", DirForward.String()) - require.Equal(t, "hash:ip", SetHashIP.String()) - require.Equal(t, "hash:net", SetHashNet.String()) -} - -// GetRules merges an IPv4/IPv6 pair into one FamilyAny rule and numbers the result, -// so a rule's Number counts logical (merged) rules. But nft chain edits act on the -// physical rows. mergedFamilyAnchors must map each merged rule back to its physical -// anchor so InsertRule/MoveRule place rules where the caller's Number says. -func TestMergedFamilyAnchorsMatchNumbering(t *testing.T) { +// GetRules reports one rule per physical row, so a rule's Number is its own row's +// rank within its chain. An IPv4 row and its IPv6 twin each get their own Number — +// nothing is collapsed — which is what keeps InsertRule/MoveRule positions aligned +// with the physical chain a backend edits. +func TestNumberByDirectionCountsEveryRow(t *testing.T) { mk := func(fam Family, port uint16) *Rule { return &Rule{Family: fam, Proto: TCP, Port: port, Action: Accept} } - // Physical input chain: [R4(22), R6(22), C(80)] — R4/R6 are a v4/v6 pair. + // Physical input chain: [R4(22), R6(22), C(80)]. phys := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(FamilyAny, 80)} - anchors := mergedFamilyAnchors(phys) - require.Equal(t, []int{0, 2}, anchors, - "the merged pair anchors at physical index 0; C stays at physical index 2") + numberByDirection(phys) + require.Equal(t, []int{1, 2, 3}, []int{phys[0].Number, phys[1].Number, phys[2].Number}, + "the v4/v6 twins each occupy their own position") - // Cross-check against mergeFamilies + numberByDirection on an independent copy: - // each merged rule's Number must map through the anchors to its physical anchor. - cp := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(FamilyAny, 80)} - merged := mergeFamilies(cp) - numberByDirection(merged) - require.Len(t, merged, 2) - require.Equal(t, 1, merged[0].Number) - require.Equal(t, 0, anchors[merged[0].Number-1], "merged pair (Number 1) -> physical index 0") - require.Equal(t, 2, merged[1].Number) - require.Equal(t, 2, anchors[merged[1].Number-1], "C (Number 2) -> physical index 2") -} - -// A twin that is not adjacent to its anchor (a non-matching rule sits between the -// v4 and v6 rows) must still be absorbed, and the anchor order preserved. -func TestMergedFamilyAnchorsNonAdjacentTwin(t *testing.T) { - phys := []*Rule{ - {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, - {Proto: TCP, Port: 80, Action: Accept}, - {Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, + // Each chain is numbered independently, and a DirAny rule counts in the input chain. + chains := []*Rule{ + {Direction: DirInput, Proto: TCP, Port: 22, Action: Accept}, + {Direction: DirOutput, Proto: TCP, Port: 25, Action: Accept}, + {Direction: DirAny, Proto: TCP, Port: 53, Action: Accept}, + {Direction: DirForward, Proto: TCP, Port: 80, Action: Accept}, + {Direction: DirOutput, Proto: TCP, Port: 443, Action: Accept}, } - anchors := mergedFamilyAnchors(phys) - require.Equal(t, []int{0, 1}, anchors, - "R4 anchors the pair at index 0; the port-80 rule stays at index 1; R6 is absorbed") -} - -// The NAT anchor mapping mirrors the filter one. -func TestMergedNATFamilyAnchors(t *testing.T) { - phys := []*NATRule{ - {Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}, - {Kind: DNAT, Family: IPv6, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}, - {Kind: DNAT, Proto: TCP, Port: 443, ToAddress: "10.0.0.6", ToPort: 8443}, - } - anchors := mergedNATFamilyAnchors(phys) - require.Equal(t, []int{0, 2}, anchors) - require.Equal(t, 2, mergedInsertIndex(anchors, len(phys), 2), - "NAT insert before merged position 2 must target physical index 2") + numberByDirection(chains) + require.Equal(t, 1, chains[0].Number, "first input rule") + require.Equal(t, 1, chains[1].Number, "first output rule") + require.Equal(t, 2, chains[2].Number, "DirAny numbers in the input chain") + require.Equal(t, 1, chains[3].Number, "first forward rule") + require.Equal(t, 2, chains[4].Number, "second output rule") } // recordManager is a minimal Manager that records mutations and does NOT @@ -1377,3 +1364,328 @@ func TestAddRulesAndSyncFallback(t *testing.T) { require.Equal(t, 1, removed) require.Len(t, m.rules, 2) } + +// TestProtocolCoverage locks in the protocol axis: a same-family tcp/udp pair covers +// the TCPUDP rule it materializes, and nothing else does. +func TestProtocolCoverage(t *testing.T) { + ported := &Rule{Family: IPv4, Port: 53, Proto: TCPUDP, Action: Accept} + require.True(t, ported.CoveredBy([]*Rule{ + {Family: IPv4, Port: 53, Proto: TCP, Action: Accept}, + {Family: IPv4, Port: 53, Proto: UDP, Action: Accept}, + }), "a tcp/udp pair covers the TCPUDP rule") + + // A portless pair covers too: `-p tcp` plus `-p udp` is exactly TCPUDP. But + // ProtocolAny is not on the axis — it matches every IP protocol, so it neither + // covers nor is covered by TCPUDP; treating it as the pair's merge would widen + // the rule to icmp, gre and the rest. + portless := &Rule{Family: IPv4, Proto: TCPUDP, Action: Accept} + require.True(t, portless.CoveredBy([]*Rule{ + {Family: IPv4, Proto: TCP, Action: Accept}, + {Family: IPv4, Proto: UDP, Action: Accept}, + })) + require.False(t, portless.CoveredBy([]*Rule{ + {Family: IPv4, Proto: ProtocolAny, Action: Accept}, + }), "ProtocolAny is not a tcp+udp rule") + require.False(t, (&Rule{Family: IPv4, Proto: ProtocolAny, Action: Accept}).CoveredBy([]*Rule{portless}), + "a TCPUDP rule does not cover every IP protocol") + + // A cross-family pair leaves half of each family uncovered. + require.False(t, ported.CoveredBy([]*Rule{ + {Family: IPv4, Port: 53, Proto: TCP, Action: Accept}, + {Family: IPv6, Port: 53, Proto: UDP, Action: Accept}, + }), "a cross-family tcp/udp pair must not be read as coverage") + + // A same-protocol duplicate is not cross-transport coverage. + require.False(t, ported.CoveredBy([]*Rule{ + {Family: IPv4, Port: 53, Proto: TCP, Action: Accept}, + {Family: IPv4, Port: 53, Proto: TCP, Action: Accept}, + })) + + // Rules differing in another field never cover. + require.False(t, ported.CoveredBy([]*Rule{ + {Family: IPv4, Port: 53, Proto: TCP, Action: Accept}, + {Family: IPv4, Port: 54, Proto: UDP, Action: Accept}, + })) + + // One native TCPUDP row covers both transports on its own, and covers each half. + require.True(t, ported.CoveredBy([]*Rule{ported})) + require.True(t, ported.Covers(&Rule{Family: IPv4, Port: 53, Proto: TCP, Action: Accept})) + require.False(t, (&Rule{Family: IPv4, Port: 53, Proto: TCP, Action: Accept}).Covers(ported), + "coverage is asymmetric") +} + +// TestExpandProtocols covers the write-side fan-out a backend with no both-transports +// form applies before marshalling. +func TestExpandProtocols(t *testing.T) { + rows := expandProtocols(&Rule{Port: 53, Proto: TCPUDP, Action: Accept}) + require.Len(t, rows, 2) + require.Equal(t, TCP, rows[0].Proto) + require.Equal(t, UDP, rows[1].Proto) + + // A concrete transport, and the genuinely-every-protocol match, pass through. + require.Len(t, expandProtocols(&Rule{Port: 53, Proto: TCP}), 1) + require.Len(t, expandProtocols(&Rule{Proto: ProtocolAny}), 1) + + // The caller's rule is untouched. + src := &Rule{Port: 53, Proto: TCPUDP} + _ = expandProtocols(src) + require.Equal(t, TCPUDP, src.Proto) + + // The rows a TCPUDP rule expands to cover it exactly, so a backend that fans it + // out reports a set Sync still recognizes as the rule the caller asked for. + want := &Rule{Family: IPv4, Port: 53, Proto: TCPUDP, Action: Accept} + require.True(t, want.CoveredBy(expandProtocols(want))) +} + +// TestSplitDualRowProtocol covers removing one transport of a stored TCPUDP row: +// the row goes and the untargeted transport is re-added. +func TestSplitDualRowProtocol(t *testing.T) { + row := &Rule{Family: IPv4, Port: 53, Proto: TCPUDP, Action: Accept} + + surv := splitDualRowProtocol(row, &Rule{Family: IPv4, Port: 53, Proto: TCP, Action: Accept}) + require.NotNil(t, surv) + require.Equal(t, UDP, surv.Proto, "removing tcp from a tcpudp row leaves udp") + + surv = splitDualRowProtocol(row, &Rule{Family: IPv4, Port: 53, Proto: UDP, Action: Accept}) + require.NotNil(t, surv) + require.Equal(t, TCP, surv.Proto) + + // A TCPUDP target clears the whole row, so there is nothing to re-add. + require.Nil(t, splitDualRowProtocol(row, &Rule{Proto: TCPUDP})) + // A concrete row is not a merged row. + require.Nil(t, splitDualRowProtocol(&Rule{Proto: TCP}, &Rule{Proto: TCP})) +} + +// TestCoversProtocol pins the asymmetric add form and the symmetric remove form. +func TestCoversProtocol(t *testing.T) { + // Add/dedup: a stored TCPUDP row makes a concrete add redundant, but not vice + // versa — a stored tcp row leaves udp unprotected. + require.True(t, coversProtocol(TCPUDP, TCP)) + require.True(t, coversProtocol(TCPUDP, UDP)) + require.False(t, coversProtocol(TCP, TCPUDP)) + require.False(t, coversProtocol(TCP, UDP)) + require.True(t, coversProtocol(TCP, TCP)) + // ProtocolAny is not a merged value and covers only itself. + require.False(t, coversProtocol(ProtocolAny, TCP)) + require.True(t, coversProtocol(ProtocolAny, ProtocolAny)) + + // Remove/move: either side may be the merged value. + require.True(t, coversProtocolRemoval(TCPUDP, TCP)) + require.True(t, coversProtocolRemoval(TCP, TCPUDP)) + require.False(t, coversProtocolRemoval(TCP, UDP)) + require.False(t, coversProtocolRemoval(ProtocolAny, TCP)) +} + +// TestEqualForRemovalProtocolCoverage: a TCPUDP target must match each concrete row +// it covers (so one RemoveRule clears both), and a concrete target must match a +// stored TCPUDP row (so the caller can split it). +func TestEqualForRemovalProtocolCoverage(t *testing.T) { + tcpRow := &Rule{Family: IPv4, Port: 53, Proto: TCP, Action: Accept} + udpRow := &Rule{Family: IPv4, Port: 53, Proto: UDP, Action: Accept} + bothRow := &Rule{Family: IPv4, Port: 53, Proto: TCPUDP, Action: Accept} + + require.True(t, tcpRow.EqualForRemoval(bothRow, true)) + require.True(t, udpRow.EqualForRemoval(bothRow, true)) + require.True(t, bothRow.EqualForRemoval(tcpRow, true)) + require.False(t, tcpRow.EqualForRemoval(udpRow, true), "acting on tcp must never disturb udp") + + // Dedup: a stored TCPUDP row absorbs a concrete add; a stored tcp row does not + // absorb a TCPUDP add (that would leave udp unprotected). + require.True(t, bothRow.EqualForDedup(tcpRow, true)) + require.False(t, tcpRow.EqualForDedup(bothRow, true)) +} + +// A rule spanning all three axes has eight cells, so a backend that can store none of +// them reports it as eight physical rows. Those rows cover it exactly, and dropping +// any one of them breaks the coverage — which is what keeps Sync from re-adding a +// rule that is already fully installed, and from calling a half-installed rule done. +func TestAllThreeAxesCoverage(t *testing.T) { + // No address, so nothing pins the family: the rule genuinely spans both. + var rows []*Rule + for _, fam := range []Family{IPv4, IPv6} { + for _, proto := range []Protocol{TCP, UDP} { + rows = append(rows, + &Rule{Family: fam, Proto: proto, Port: 53, Direction: DirInput, Action: Accept}, + &Rule{Family: fam, Proto: proto, SourcePort: 53, Direction: DirOutput, Action: Accept}, + ) + } + } + require.Len(t, rows, 8) + + want := &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Direction: DirAny, Action: Accept} + require.Len(t, want.cells(true), 8, "the rule spans eight concrete cells") + require.True(t, want.CoveredBy(rows), "the eight rows cover the rule") + + for i := range rows { + missing := append(append([]*Rule{}, rows[:i]...), rows[i+1:]...) + require.False(t, want.CoveredBy(missing), "dropping row %d must break coverage", i) + } + + // An address pins the family, so the same rule then spans only four cells: the + // direction swap moves it to the destination, and both halves stay IPv4. + addressed := &Rule{Proto: TCPUDP, Source: "192.0.2.1", Port: 53, Direction: DirAny, Action: Accept} + require.Len(t, addressed.cells(true), 4, "an IPv4 source pins the family axis") +} + +// TestSyncCanonicalizesProtocol: a caller that lists tcp and udp separately must not +// churn against a backend that stores the pair as one native TCPUDP rule. +func TestSyncCanonicalizesProtocol(t *testing.T) { + m := &syncStateMock{existing: []*Rule{ + {Family: IPv4, Port: 53, Proto: TCPUDP, Action: Accept}, + }} + desired := []*Rule{ + {Family: IPv4, Port: 53, Proto: TCP, Action: Accept}, + {Family: IPv4, Port: 53, Proto: UDP, Action: Accept}, + } + added, removed, err := Sync(context.Background(), m, "", desired) + require.NoError(t, err) + require.Equal(t, 0, added, "the merged rule already covers both transports") + require.Equal(t, 0, removed, "Sync must not remove-and-re-add a merged rule") + require.Equal(t, TCP, desired[0].Proto, "Sync must not mutate the caller's desired rules") +} + +// TestProtocolTCPUDPSerialization: TCPUDP must survive the Backup/Restore JSON round +// trip under its own stable name. Reading it back as ProtocolAny would silently widen +// a restored rule from two transports to every IP protocol. +func TestProtocolTCPUDPSerialization(t *testing.T) { + require.Equal(t, "tcpudp", TCPUDP.String()) + require.Equal(t, TCPUDP, GetProtocol("tcpudp")) + require.Equal(t, TCPUDP, GetProtocol("TCPUDP"), "protocol names are case-insensitive") + + data, err := json.Marshal(TCPUDP) + require.NoError(t, err) + require.JSONEq(t, `"tcpudp"`, string(data)) + + var back Protocol + require.NoError(t, json.Unmarshal(data, &back)) + require.Equal(t, TCPUDP, back) + + // TCPUDP carries ports; ProtocolAny does not, so a port on it stays rejected. + require.True(t, TCPUDP.HasPorts()) + require.False(t, ProtocolAny.HasPorts()) + require.False(t, (&Rule{Proto: TCPUDP, Port: 80}).PortNeedsConcreteProtocol()) + require.True(t, (&Rule{Proto: ProtocolAny, Port: 80}).PortNeedsConcreteProtocol()) + + // A row-level marshaller must never see the merged value. + require.Error(t, (&Rule{Proto: TCPUDP}).CheckExpandedProtocol()) + require.NoError(t, (&Rule{Proto: TCP}).CheckExpandedProtocol()) +} + +// TestRuleCovers pins the exported coverage relation: a merged rule contains its +// concrete halves on every axis, never the reverse, and ProtocolAny is not a merged +// value. +func TestRuleCovers(t *testing.T) { + merged := &Rule{Family: FamilyAny, Proto: TCPUDP, Direction: DirAny, Port: 53, Action: Accept} + cell := &Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 53, Action: Accept} + + require.True(t, merged.Covers(cell), "a rule merged on every axis covers each of its cells") + require.False(t, cell.Covers(merged), "coverage is asymmetric: a concrete rule cannot cover a merged one") + require.True(t, cell.Covers(cell), "a rule covers itself") + + // Each axis independently. + require.True(t, (&Rule{Family: FamilyAny, Proto: TCP, Port: 53, Action: Accept}). + Covers(&Rule{Family: IPv6, Proto: TCP, Port: 53, Action: Accept})) + require.True(t, (&Rule{Proto: TCPUDP, Port: 53, Action: Accept}). + Covers(&Rule{Proto: UDP, Port: 53, Action: Accept})) + require.True(t, (&Rule{Proto: TCP, Direction: DirAny, Port: 53, Action: Accept}). + Covers(&Rule{Proto: TCP, Direction: DirOutput, SourcePort: 53, Action: Accept}), + "a DirAny rule covers its role-swapped output half") + + // Siblings never cover each other. + require.False(t, (&Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}). + Covers(&Rule{Family: IPv6, Proto: TCP, Port: 53, Action: Accept})) + require.False(t, (&Rule{Proto: TCP, Port: 53, Action: Accept}). + Covers(&Rule{Proto: UDP, Port: 53, Action: Accept})) + + // ProtocolAny matches every IP protocol; it is not the merged tcp/udp value and + // so covers neither transport. + require.False(t, (&Rule{Proto: ProtocolAny, Action: Accept}).Covers(&Rule{Proto: TCP, Action: Accept})) + require.True(t, (&Rule{Proto: ProtocolAny, Action: Accept}).Covers(&Rule{Proto: ProtocolAny, Action: Accept})) + + // An ordinary field must still match exactly. + require.False(t, merged.Covers(&Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 53, Action: Drop}), + "a different action is a different rule") + require.False(t, merged.Covers(&Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 54, Action: Accept})) +} + +// TestCoveredByAcrossSeveralRules is why CoveredBy exists: a merged rule's coverage +// may be spread across several stored rules that no single rule Covers. +func TestCoveredByAcrossSeveralRules(t *testing.T) { + want := &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept} + + // The four concrete cells, held separately. No single one covers want. + four := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 53, Action: Accept}, + {Family: IPv6, Proto: TCP, Port: 53, Action: Accept}, + {Family: IPv4, Proto: UDP, Port: 53, Action: Accept}, + {Family: IPv6, Proto: UDP, Port: 53, Action: Accept}, + } + for _, r := range four { + require.False(t, r.Covers(want)) + } + require.True(t, want.CoveredBy(four), "the set covers want even though no single rule does") + + // Drop any one cell and the set no longer covers want. + for i := range four { + short := append(append([]*Rule{}, four[:i]...), four[i+1:]...) + require.False(t, want.CoveredBy(short), "removing cell %d must leave want uncovered", i) + } + + // One fully merged rule covers it, as does a mixed set. + require.True(t, want.CoveredBy([]*Rule{{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}})) + require.True(t, want.CoveredBy([]*Rule{ + {Family: FamilyAny, Proto: TCP, Port: 53, Action: Accept}, + {Family: IPv4, Proto: UDP, Port: 53, Action: Accept}, + {Family: IPv6, Proto: UDP, Port: 53, Action: Accept}, + })) + + // A concrete want is covered by a merged rule. + require.True(t, (&Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept}). + CoveredBy([]*Rule{{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}})) + + // Nothing covers anything in an empty set; want is left untouched. + require.False(t, want.CoveredBy(nil)) + require.Equal(t, TCPUDP, want.Proto) + require.Equal(t, FamilyAny, want.Family) +} + +// TestCoveredByDirection: a DirAny rule needs both directions present, and the +// outbound half is matched in its role-swapped frame. +func TestCoveredByDirection(t *testing.T) { + want := &Rule{Proto: TCP, Direction: DirAny, Source: "192.0.2.1", Port: 22, Action: Accept} + + in := &Rule{Proto: TCP, Direction: DirInput, Source: "192.0.2.1", Port: 22, Action: Accept} + out := &Rule{Proto: TCP, Direction: DirOutput, Destination: "192.0.2.1", SourcePort: 22, Action: Accept} + + require.False(t, want.CoveredBy([]*Rule{in}), "the input half alone does not cover a both-directions rule") + require.True(t, want.CoveredBy([]*Rule{in, out}), "the swapped output twin completes the coverage") + + // A forward rule has no opposite direction and stands alone. + fwd := &Rule{Proto: TCP, Direction: DirForward, Port: 22, Action: Accept} + require.True(t, fwd.CoveredBy([]*Rule{fwd})) + require.False(t, fwd.CoveredBy([]*Rule{in, out})) +} + +// TestNATCoveredBy mirrors Rule.CoveredBy over the single axis NAT merges on. A Redirect +// carries no translation address, so its family is genuinely FamilyAny — a DNAT's +// ToAddress would pin the family through impliedFamily. +func TestNATCoveredBy(t *testing.T) { + want := &NATRule{Kind: Redirect, Family: FamilyAny, Proto: TCP, Port: 80, ToPort: 8080} + v4 := &NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080} + v6 := &NATRule{Kind: Redirect, Family: IPv6, Proto: TCP, Port: 80, ToPort: 8080} + + require.True(t, want.Covers(v4)) + require.False(t, v4.Covers(want)) + require.False(t, v4.Covers(v6)) + + require.False(t, want.CoveredBy([]*NATRule{v4})) + require.True(t, want.CoveredBy([]*NATRule{v4, v6})) + require.True(t, v6.CoveredBy([]*NATRule{want})) + + // A DNAT's translation address pins the family, so a FamilyAny DNAT to an IPv4 + // target is already an IPv4 rule and one concrete rule covers it. + dnatAny := &NATRule{Kind: DNAT, Family: FamilyAny, Proto: TCP, Port: 80, ToAddress: "192.0.2.9"} + dnatV4 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "192.0.2.9"} + require.True(t, dnatAny.CoveredBy([]*NATRule{dnatV4}), + "the translation address already pins this rule to IPv4") +} diff --git a/firewalld_linux.go b/firewalld_linux.go index 125f6ba..878a5d4 100644 --- a/firewalld_linux.go +++ b/firewalld_linux.go @@ -11,9 +11,6 @@ import ( firewalld "github.com/grmrgecko/go-firewalld" ) -// FirewallDType is the backend identifier reported by FirewallD.Type. -const FirewallDType = "firewalld" - // 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) { @@ -523,10 +520,11 @@ func (f *FirewallD) GetRules(ctx context.Context, zoneName string) (rules []*Rul 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) + // Every entry above is reported as firewalld stores it. A rich rule with no + // `family=` attribute, and a zone port or source, cover both IP families as one + // object and read back as FamilyAny on their own; a rich rule's port element + // carries exactly one protocol, so a both-transports rule is two entries and is + // reported as two 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 @@ -615,6 +613,13 @@ func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) { if r.IsForward() { return "", unsupportedForward("firewalld") } + // A rich rule's port element carries a single protocol, so a TCPUDP rule + // has no single rich-rule form; AddRule/RemoveRule fan it into a tcp row and a + // udp row with expandProtocols before this row-level marshaller runs. A TCPUDP + // rule reaching here means that fan-out was skipped. + if err := r.CheckExpandedProtocol(); err != nil { + return "", err + } // A port in a rich rule requires a concrete protocol; `protocol="any"` is // not valid, so reject rather than emit a rule firewalld will refuse. if r.PortNeedsConcreteProtocol() { @@ -869,6 +874,18 @@ func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error // DirAny rule cannot be expressed, so it degrades to its input half. r = dirAnyInputFallback(r, f.Capabilities().Output) + // A firewalld zone port and rich rule each carry a single protocol, so a + // TCPUDP rule has no single form; fan it into a tcp add and a udp add, each of + // which routes through the zone-port or rich-rule path below on its own. + if subs := expandProtocols(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + // A connection-count limit cannot be expressed in a firewalld rich rule. if r.ConnLimit != nil { return fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit) @@ -988,6 +1005,19 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er // mirroring AddRule so a both-directions rule is found and removed as stored. r = dirAnyInputFallback(r, f.Capabilities().Output) + // firewalld stores a TCPUDP rule as two concrete rows (a tcp entry and a udp + // entry), never one shared row, so such a removal fans into a tcp remove and a + // udp remove — the exact inverse of AddRule's fan-out. Removing a concrete + // transport still deletes only its own row, so no protocol-axis split is needed. + if subs := expandProtocols(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + // Get the zone. zoneName, err := f.resolveZoneName(ctx, zoneName) if err != nil { @@ -1106,11 +1136,10 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er // Rich-rule path: read the zone settings and remove every stored rich rule // whose parsed form matches the target, so a rule firewalld stored with // different formatting than our marshaller produces is still matched. Match with - // EqualForRemoval rather than the family-strict Equal: GetRules merges an - // IPv4/IPv6 rich-rule twin (what a concrete-family bare-port accept becomes) - // into one FamilyAny rule, so removing that read-back rule must clear both - // underlying rich rules, while a concrete-family target still removes only its - // own family. + // EqualForRemoval rather than the family-strict Equal: a FamilyAny target must + // clear both the familyless rich rule it names and any family-pinned rich rules + // it covers, while a concrete-family target still removes only its own family + // (splitting a familyless rich rule when it matches one). settings, err := zone.Settings(ctx) if err != nil { return err @@ -1309,104 +1338,6 @@ func (f *FirewallD) RemoveNATRule(ctx context.Context, zoneName string, r *NATRu return fmt.Errorf("invalid nat kind") } -// 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) - if err != nil { - return nil, err - } - natRules, err := f.GetNATRules(ctx, zoneName) - if err != nil { - return nil, err - } - // GetRules/GetNATRules are already scoped to this zone, so the backup captures - // exactly the zone's rules; captureBackupState adds the zone's default policy - // (its target) and the managed ipsets. - 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 *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup) error { - if backup == nil { - return fmt.Errorf("backup cannot be nil") - } - - // Get the zone. - zoneName, err := f.resolveZoneName(ctx, zoneName) - if err != nil { - return err - } - zone := f.Conn.Permanent().Zone(zoneName) - - settings, err := zone.Settings(ctx) - if err != nil { - return err - } - - // Remove all ports, sources, and rich rules that we can decode as managed. - for _, port := range settings.Ports { - if err := zone.RemovePort(ctx, port); err != nil { - return err - } - } - for _, sp := range settings.SourcePorts { - if err := zone.RemoveSourcePort(ctx, sp); err != nil { - return err - } - } - for _, source := range settings.Sources { - if err := zone.RemoveSource(ctx, source); err != nil { - return err - } - } - for _, proto := range settings.Protocols { - if err := zone.RemoveProtocol(ctx, proto); err != nil { - return err - } - } - for _, richRule := range settings.RichRules { - if err := zone.RemoveRichRule(ctx, richRule); err != nil { - return err - } - } - - // Remove NAT rules. - for _, fp := range settings.ForwardPorts { - if err := zone.RemoveForwardPort(ctx, fp); err != nil { - return err - } - } - if settings.Masquerade { - if err := zone.RemoveMasquerade(ctx); err != nil { - return err - } - } - - // Recreate the ipsets before the rules that reference them (the managed rich - // rules were removed above, so nothing holds a set reference). - 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); err != nil { - return err - } - } - for _, r := range backup.NATRules { - if err := f.AddNATRule(ctx, zoneName, r); err != nil { - return err - } - } - // Re-assert the zone's captured default policy (its target). - return applyBackupPolicy(ctx, f, zoneName, backup) -} - // 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. @@ -1599,6 +1530,104 @@ func (f *FirewallD) RemoveAddressSetEntry(ctx context.Context, name, entry strin return err } +// 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) + if err != nil { + return nil, err + } + natRules, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return nil, err + } + // GetRules/GetNATRules are already scoped to this zone, so the backup captures + // exactly the zone's rules; captureBackupState adds the zone's default policy + // (its target) and the managed ipsets. + 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 *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + + // Get the zone. + zoneName, err := f.resolveZoneName(ctx, zoneName) + if err != nil { + return err + } + zone := f.Conn.Permanent().Zone(zoneName) + + settings, err := zone.Settings(ctx) + if err != nil { + return err + } + + // Remove all ports, sources, and rich rules that we can decode as managed. + for _, port := range settings.Ports { + if err := zone.RemovePort(ctx, port); err != nil { + return err + } + } + for _, sp := range settings.SourcePorts { + if err := zone.RemoveSourcePort(ctx, sp); err != nil { + return err + } + } + for _, source := range settings.Sources { + if err := zone.RemoveSource(ctx, source); err != nil { + return err + } + } + for _, proto := range settings.Protocols { + if err := zone.RemoveProtocol(ctx, proto); err != nil { + return err + } + } + for _, richRule := range settings.RichRules { + if err := zone.RemoveRichRule(ctx, richRule); err != nil { + return err + } + } + + // Remove NAT rules. + for _, fp := range settings.ForwardPorts { + if err := zone.RemoveForwardPort(ctx, fp); err != nil { + return err + } + } + if settings.Masquerade { + if err := zone.RemoveMasquerade(ctx); err != nil { + return err + } + } + + // Recreate the ipsets before the rules that reference them (the managed rich + // rules were removed above, so nothing holds a set reference). + 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); err != nil { + return err + } + } + for _, r := range backup.NATRules { + if err := f.AddNATRule(ctx, zoneName, r); err != nil { + return err + } + } + // Re-assert the zone's captured default policy (its target). + 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) diff --git a/firewalld_linux_test.go b/firewalld_linux_test.go index 6549f26..b08fd9e 100644 --- a/firewalld_linux_test.go +++ b/firewalld_linux_test.go @@ -1,7 +1,6 @@ package firewall import ( - "errors" "testing" firewalld "github.com/grmrgecko/go-firewalld" @@ -87,7 +86,6 @@ func TestFirewallDRichRules(t *testing.T) { // rejected with the ErrUnsupportedForward sentinel. _, err = fw.MarshalRichRule(&Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept}) require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected") - require.False(t, fw.Capabilities().Forward, "firewalld does not advertise forward support") } func TestFirewallDFeatureRules(t *testing.T) { @@ -285,31 +283,74 @@ func TestFirewallDSourcePort(t *testing.T) { } // A concrete-family bare-port accept is stored as a rich rule (zoneEntryEligible -// requires FamilyAny), so a v4/v6 pair becomes two rich rules that GetRules merges -// into one FamilyAny rule. RemoveRule must locate that merged rule against the -// stored rich rules with EqualForRemoval — the family-strict Equal it used -// before matched neither, so the port stayed open. Regression for the firewalld -// merged-family remove no-op surfaced by the integration suite. -func TestFirewallDMergedRichRuleRemovable(t *testing.T) { +// requires FamilyAny), so a family-agnostic port opened per family becomes two rich +// rules. Removing it with a FamilyAny target must locate both against the stored rich +// rules with EqualForRemoval — the family-strict Equal it used before matched +// neither, so the port stayed open. Regression for the firewalld family-agnostic +// remove no-op surfaced by the integration suite. +func TestFirewallDFamilyAnyRichRuleRemovable(t *testing.T) { fw := new(FirewallD) v4, err := fw.UnmarshalRichRule(`rule family="ipv4" port port="3492" protocol="tcp" accept`) require.NoError(t, err) v6, err := fw.UnmarshalRichRule(`rule family="ipv6" port port="3492" protocol="tcp" accept`) require.NoError(t, err) - merged := mergeFamiliesCopy([]*Rule{v4, v6}) - require.Len(t, merged, 1, "the v4/v6 rich-rule twin must collapse into one rule") - m := merged[0] - require.Equal(t, FamilyAny, m.impliedFamily()) + // The two stored rich rules cover the family-agnostic rule between them. + target := &Rule{Family: FamilyAny, Proto: TCP, Port: 3492, Action: Accept} + require.True(t, target.CoveredBy([]*Rule{v4, v6})) - // The old family-strict matcher found neither stored rich rule. - require.False(t, m.Equal(v4, false)) - require.False(t, m.Equal(v6, false)) + // The family-strict matcher finds neither stored rich rule. + require.False(t, target.Equal(v4, false)) + require.False(t, target.Equal(v6, false)) - // The new matcher (EqualForRemoval) finds both, so RemoveRule clears both rich - // rules for a merged read-back rule. - require.True(t, v4.EqualForRemoval(m, false)) - require.True(t, v6.EqualForRemoval(m, false)) + // EqualForRemoval finds both, so RemoveRule clears every rich rule the target + // covers. + require.True(t, v4.EqualForRemoval(target, false)) + require.True(t, v6.EqualForRemoval(target, false)) +} + +// A merged TCPUDP rule has no single rich-rule form (a rich rule port element +// carries one protocol), so the row-level marshaller must reject it: AddRule and +// RemoveRule fan it into a tcp row and a udp row with expandProtocols before +// marshalling. ProtocolAny+ports stays rejected too, since it is a distinct value. +func TestFirewallDMarshalRejectsMergedProtocol(t *testing.T) { + fw := new(FirewallD) + + // A TCPUDP port rule reaching the row marshaller means the fan-out was skipped. + _, err := fw.MarshalRichRule(&Rule{Port: 80, Proto: TCPUDP, Action: Accept}) + require.Error(t, err, "a TCPUDP rule must not marshal to a single rich rule") + + // expandProtocols yields a tcp row and a udp row, each of which marshals cleanly. + subs := expandProtocols(&Rule{Port: 80, Proto: TCPUDP, Action: Accept}) + require.Len(t, subs, 2, "a TCPUDP rule fans into a tcp row and a udp row") + require.Equal(t, TCP, subs[0].Proto) + require.Equal(t, UDP, subs[1].Proto) + for _, sub := range subs { + rich, merr := fw.MarshalRichRule(sub) + require.NoError(t, merr, "an expanded transport row must marshal: %+v", *sub) + require.Contains(t, rich, `protocol="`+sub.Proto.String()+`"`) + } +} + +// A rich rule's port element carries one protocol, so a port opened for both tcp and +// udp in the same zone is two rich rules. Each reads back as its own rule (both +// family-agnostic, since neither names a family), and together they cover the TCPUDP +// rule that was written. A TCPUDP target reaches both on removal. +func TestFirewallDTCPUDPRichRulePair(t *testing.T) { + fw := new(FirewallD) + tcp, err := fw.UnmarshalRichRule(`rule port port="3492" protocol="tcp" accept`) + require.NoError(t, err) + udp, err := fw.UnmarshalRichRule(`rule port port="3492" protocol="udp" accept`) + require.NoError(t, err) + require.Equal(t, FamilyAny, tcp.impliedFamily(), "a familyless rich rule covers both families") + + target := &Rule{Proto: TCPUDP, Port: 3492, Action: Accept} + require.True(t, target.CoveredBy([]*Rule{tcp, udp}), "the tcp/udp rich-rule pair covers the TCPUDP rule") + require.False(t, target.CoveredBy([]*Rule{tcp}), "the tcp rich rule alone leaves udp uncovered") + + // The TCPUDP target reaches each concrete stored row on removal. + require.True(t, tcp.EqualForRemoval(target, false)) + require.True(t, udp.EqualForRemoval(target, false)) } // A rich rule cannot express a specific rate-limit burst, but the netfilter default @@ -482,26 +523,6 @@ func TestFirewallDSourceProtoNotZoneSource(t *testing.T) { require.Contains(t, rich, `protocol value="tcp"`, "the protocol match must survive on the rich-rule path") } -// A firewalld rich rule limit is a bare rate with no burst allowance. A non-zero -// Burst must be rejected rather than silently dropped: a dropped burst reads back -// as 0, so Rule.Equal never matches the desired rule and Sync churns forever. A -// zero Burst (backend default) round-trips. -func TestFirewalldRateBurstRejected(t *testing.T) { - fw := new(FirewallD) - - r0 := &Rule{Action: Accept, Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}} - rr, err := fw.MarshalRichRule(r0) - require.NoError(t, err) - back, err := fw.UnmarshalRichRule(rr) - require.NoError(t, err) - require.True(t, r0.Equal(back, false), "burst-0 rate limit must round-trip; got %+v", back.RateLimit) - - rb := &Rule{Action: Accept, Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 20}} - _, err = fw.MarshalRichRule(rb) - require.Error(t, err, "a non-zero burst must be rejected, not silently dropped") - require.True(t, errors.Is(err, ErrUnsupportedRateLimit), "error should wrap ErrUnsupportedRateLimit, got: %v", err) -} - // firewalld expresses the portless protocols as a bare protocol element and // SCTP as a port protocol; both round-trip through a rich rule. func TestFirewallDProtocolExtras(t *testing.T) { diff --git a/hooks_linux.go b/hooks_linux.go index d958a92..59a9866 100644 --- a/hooks_linux.go +++ b/hooks_linux.go @@ -49,6 +49,21 @@ func ruleNeedsHook(r *Rule) bool { isSetRef(r.Source) || isSetRef(r.Destination) } +// ipv6Unavailable reports whether r resolves to IPv6 while the backend's own IPv6 +// handling is switched off (csf.conf IPV6, conf.apf USE_IPV6). Neither backend can +// enforce such a rule: its native config silently drops or ignores every IPv6 form, +// and the raw-iptables hook is no escape hatch — with IPv6 off, neither firewall +// flushes ip6tables on (re)load (csf.pl guards the whole v6 flush behind IPV6; apf's +// ipt6() is a no-op unless USE_IPV6=1), so a hook-injected ip6tables line is +// re-appended on every reload and a line the library removes from the hook lives on +// in the kernel. Both backends therefore reject a concrete-IPv6 rule outright rather +// than write one they cannot manage. A FamilyAny rule is not concrete IPv6 and is +// unaffected: it is written for whichever family the backend enforces. Shared by CSF +// and APF. +func ipv6Unavailable(ipv6Enabled bool, r *Rule) bool { + return !ipv6Enabled && r.impliedFamily() == IPv6 +} + // bareHostShape reports whether a rule has the shape a plain csf.allow/apf // allow_hosts line expresses: exactly one source or destination address, no ports, // and the any-protocol match. Its direction is not considered — a DirAny bare host @@ -101,9 +116,11 @@ func hostNeedsHook(r *Rule) bool { if r.Source != "" && r.Destination != "" { return true } - // A single-address host pinned to tcp/udp has no portless form. + // A single-address host pinned to a transport has no portless form: the plain + // line is all-protocol and the advanced rule requires a port. TCPUDP counts — + // it names transports, so it is not the all-protocol plain line either. if r.Source != "" || r.Destination != "" { - return r.Proto == TCP || r.Proto == UDP + return onProtocolAxis(r.Proto) } return false } @@ -130,16 +147,15 @@ func hookOnlyProto(p Protocol) bool { } // hookRuleProtos lists the transport protocols a rule is written for in the hook. -// A ProtocolAny rule that carries a port has no single iptables form — a --dport/ -// --sport match requires a concrete -p tcp/udp — so it fans out into a tcp line and -// a udp line, mirroring the tcp+udp fan-out csf/apf write for a ProtocolAny port -// rule in their native config. A portless ProtocolAny rule is a valid protocol- -// agnostic match (a bare `-j ACCEPT`), so it keeps its own protocol and is not fanned. +// iptables has no both-transports match, so a TCPUDP rule fans out into a tcp line +// and a udp line; every other protocol writes one line. A portless ProtocolAny rule +// is a valid protocol-agnostic match (a bare `-j ACCEPT`) and is not fanned. func hookRuleProtos(r *Rule) []Protocol { - if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) { - return []Protocol{TCP, UDP} + protos := make([]Protocol, 0, 2) + for _, sub := range expandProtocols(r) { + protos = append(protos, sub.Proto) } - return []Protocol{r.Proto} + return protos } // hookRuleFamilies lists the address families a rule is written for: a rule diff --git a/hooks_linux_test.go b/hooks_linux_test.go index 4f73dfe..009f56e 100644 --- a/hooks_linux_test.go +++ b/hooks_linux_test.go @@ -40,6 +40,49 @@ func TestRuleNeedsHook(t *testing.T) { } } +// With csf.conf's IPV6 or conf.apf's USE_IPV6 off (the shipped default in both), +// neither backend enforces any IPv6, and the raw-iptables hook is no escape hatch: +// neither firewall flushes ip6tables on reload, so a hook-injected v6 line is +// re-appended on every reload and a removed one lives on in the kernel. +// ipv6Unavailable must therefore flag every concrete-IPv6 rule — including the +// shapes each backend would otherwise route to the hook (ICMPv6, a stateful match, +// a single-family bare port) — and only when the backend's IPv6 handling is off. +func TestIPv6UnavailableGate(t *testing.T) { + // Every concrete-IPv6 shape is blocked with the backend's IPv6 handling off, and + // allowed with it on. Family is implied by a v6 address or the ICMPv6 protocol + // where it is not set outright. + blocked := []*Rule{ + // A v6 address in a trust-file line (plain) or an advanced rule (with a port). + {Proto: ProtocolAny, Source: "2001:db8::1", Action: Accept}, + {Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept}, + // A port-only v6 deny carries no address (csf synthesizes ::/0 on write), so + // the gate must key on the implied family alone. + {Family: IPv6, Proto: TCP, Port: 8080, Action: Drop}, + // apf's native ICMPv6 type list, and the ICMPv6 shapes both backends hook. + {Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, + {Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept}, + // A single-family bare port accept, which apf routes to the hook. + {Family: IPv6, Proto: TCP, Port: 8090, Action: Accept}, + } + for _, r := range blocked { + require.True(t, ipv6Unavailable(false, r), "expected %+v to be blocked with IPv6 off", *r) + require.False(t, ipv6Unavailable(true, r), "expected %+v to be allowed with IPv6 on", *r) + } + + // A rule that resolves to IPv4, or to neither family, is never blocked: a + // FamilyAny rule is written for whichever family the backend enforces. + allowed := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept}, + {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, + {Proto: TCP, Port: 8080, Action: Drop}, + {Proto: TCP, Port: 22, Action: Accept, State: StateNew}, + } + for _, r := range allowed { + require.False(t, ipv6Unavailable(false, r), "expected %+v to pass the IPv6 gate", *r) + require.False(t, ipv6Unavailable(true, r), "expected %+v to pass the IPv6 gate", *r) + } +} + // bareProtoNeedsHook routes a portless, addressless non-ICMP match to the hook — // the shape CSF/APF cannot express natively but iptables applies directly — while // leaving every rule that carries an address, a port, or an ICMP protocol on its @@ -105,14 +148,13 @@ func TestHookScriptPortOrderIdempotent(t *testing.T) { require.Empty(t, got, "the rule must be gone after removal") } -// A ProtocolAny rule that carries a port has no single iptables form — a --dport -// match requires a concrete -p tcp/udp — so the hook fans it out into a tcp line and -// a udp line, mirroring the tcp+udp fan-out csf/apf write in their native config. -// Both add and remove must fan out and never reject the rule for want of a concrete -// protocol. Regression: csf's GetRules merges a same-port TCP_IN/UDP_IN pair back -// into one ProtocolAny rule, so a Backup captured it and Restore's hook-copy clear -// then failed to marshal it, breaking the whole restore. -func TestHookScriptProtocolAnyPortFansOut(t *testing.T) { +// A TCPUDP rule has no single iptables form — one line matches one -p — so the hook +// fans it out into a tcp line and a udp line, mirroring the tcp+udp fan-out csf/apf +// write in their native config. Both add and remove must fan out and never reject +// the rule for want of a concrete protocol. Regression: a Backup could hold a TCPUDP +// rule, and Restore's hook-copy clear then failed to marshal it, breaking the whole +// restore. +func TestHookScriptTCPUDPPortFansOut(t *testing.T) { dir := t.TempDir() h := &hookScript{ rulePrefix: "go_firewall", @@ -120,30 +162,30 @@ func TestHookScriptProtocolAnyPortFansOut(t *testing.T) { hookPerm: 0700, } - // Adding a ProtocolAny port rule injects a tcp line and a udp line. - any := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 20, Action: Accept} + // Adding a TCPUDP port rule injects a tcp line and a udp line. + any := &Rule{Family: IPv4, Proto: TCPUDP, Port: 20, Action: Accept} changed, err := h.edit(any, false) - require.NoError(t, err, "a ProtocolAny port rule must marshal, not be rejected") + require.NoError(t, err, "a TCPUDP port rule must marshal, not be rejected") require.True(t, changed) got, err := h.getRules() require.NoError(t, err) - require.Len(t, got, 2, "a ProtocolAny port rule fans out into a tcp and a udp hook line") + require.Len(t, got, 2, "a TCPUDP port rule fans out into a tcp and a udp hook line") protos := map[Protocol]bool{} for _, g := range got { protos[g.Proto] = true } require.True(t, protos[TCP] && protos[UDP], "the fan-out must cover both tcp and udp: %+v", got) - // Removing the ProtocolAny form clears both concrete copies in one call, without + // Removing the TCPUDP form clears both concrete copies in one call, without // erroring on the port-without-concrete-protocol shape. changed, err = h.edit(any, true) - require.NoError(t, err, "removing a ProtocolAny port rule must not fail to marshal") - require.True(t, changed, "the ProtocolAny remove must clear the tcp and udp copies") + require.NoError(t, err, "removing a TCPUDP port rule must not fail to marshal") + require.True(t, changed, "the TCPUDP remove must clear the tcp and udp copies") got, err = h.getRules() require.NoError(t, err) - require.Empty(t, got, "both fanned-out copies must be gone after the ProtocolAny remove") + require.Empty(t, got, "both fanned-out copies must be gone after the TCPUDP remove") } // A deny whose action differs from the CSF/APF config's STOP action has no native diff --git a/integration_linux_test.go b/integration_linux_test.go index fd3e2ea..6f70c51 100644 --- a/integration_linux_test.go +++ b/integration_linux_test.go @@ -24,3 +24,18 @@ func linuxBackends() []backendFactory { func TestIntegration(t *testing.T) { runIntegration(t, linuxBackends()) } + +// hookPlanter returns a function that writes a rule directly into the backend's +// raw-iptables pre-hook, standing in for a copy a customer added by hand, or nil when +// the backend has no pre-hook. Only csf and apf carry one. It lives here rather than +// in the shared suite because it names the Linux-only backend types, which do not +// compile for the pf and Windows targets. +func hookPlanter(mgr Manager) func(*Rule) error { + switch b := mgr.(type) { + case *APF: + return func(r *Rule) error { _, err := b.hook().edit(r, false); return err } + case *CSF: + return func(r *Rule) error { _, err := b.hook().edit(r, false); return err } + } + return nil +} diff --git a/integration_nohook_test.go b/integration_nohook_test.go new file mode 100644 index 0000000..2511987 --- /dev/null +++ b/integration_nohook_test.go @@ -0,0 +1,11 @@ +//go:build integration && !linux + +package firewall + +// hookPlanter reports that no backend on this platform has a raw-iptables pre-hook: +// the pre-hook is a csf/apf construct and both are Linux-only. The shared suite skips +// its hook probe on a nil result. See the Linux implementation in +// integration_linux_test.go. +func hookPlanter(mgr Manager) func(*Rule) error { + return nil +} diff --git a/integration_test.go b/integration_test.go index 0d49683..184332a 100644 --- a/integration_test.go +++ b/integration_test.go @@ -297,13 +297,8 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context // remove it from the config while leaving it running in the hook. Only the // csf/apf address-list backends carry a pre-hook, so plant the rule directly in // theirs to stand in for the hand-added copy. - var plant func(*Rule) error - switch b := mgr.(type) { - case *APF: - plant = func(r *Rule) error { _, err := b.hook().edit(r, false); return err } - case *CSF: - plant = func(r *Rule) error { _, err := b.hook().edit(r, false); return err } - default: + plant := hookPlanter(mgr) + if plant == nil { t.Skip("backend has no pre-hook") } @@ -335,12 +330,11 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context ) }) - t.Run("mergedfamilyremove", func(t *testing.T) { - // A v4 rule and its v6 twin differ only in Family, so GetRules collapses the - // pair into one FamilyAny rule. Removing that read-back rule must clear BOTH - // underlying rows. Regression for a family-strict remove that could not match - // a merged rule at all (a silent no-op that left the port open) or that - // removed only one of the two rows. + t.Run("familypairremove", func(t *testing.T) { + // A v4 rule and its v6 twin are two rows on most backends. Removing every rule + // the backend reports must clear them all. Regression for a family-strict + // remove that could not match a family-agnostic rule at all (a silent no-op + // that left the port open) or that removed only one of the two rows. const port = 3492 v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept} v6 := &Rule{Family: IPv6, Proto: TCP, Port: port, Action: Accept} @@ -397,9 +391,9 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context require.NoError(t, mgr.AddRule(ctx, zone, v4)) } - // Remove each rule the backend reports for this port (one merged FamilyAny - // rule, or one per family), then confirm none remain — removing the merged - // rule must not leave a twin row behind. + // Remove each rule the backend reports for this port (one family-agnostic rule, + // or one per family), then confirm none remain — a removal must not leave a + // twin row behind. for _, r := range rulesOf(t, ctx, mgr, zone) { if portRule(r) { require.NoError(t, mgr.RemoveRule(ctx, zone, r)) @@ -411,7 +405,7 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context }) t.Run("familyanysplitremove", func(t *testing.T) { - // Unlike mergedfamilyremove (which adds a v4/v6 PAIR), this adds ONE FamilyAny + // Unlike familypairremove (which adds a v4/v6 PAIR), this adds ONE FamilyAny // rule. A FamilyAny rule with no address is stored as a single dual-family // object by the unified backends (nft inet, pf without af, firewalld's // dual-stack zone entries) and as one row per family by the separated backends; @@ -539,17 +533,149 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context } t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) }) } - ports := []uint16{before, dual, after} - order0 := managedPorts(t, ctx, mgr, zone, ports) + // A family-separated backend stores the dual rule as a v4 row and a v6 row, so + // read the order of the rows that survive the split — everything but IPv6 — + // and require the split to leave exactly that sequence in place. + ports := map[uint16]bool{before: true, dual: true, after: true} + survivingOrder := func() []uint16 { + var out []uint16 + for _, r := range rulesOf(t, ctx, mgr, zone) { + if ports[r.Port] && r.impliedFamily() != IPv6 { + out = append(out, r.Port) + } + } + return out + } + order0 := survivingOrder() require.Len(t, order0, 3, "all three probe rules should be present before the split") // Split off IPv6; the surviving IPv4 row must keep the dual rule's position. require.NoError(t, mgr.RemoveRule(ctx, zone, &Rule{Family: IPv6, Proto: TCP, Port: dual, Action: Accept})) - require.Equal(t, order0, managedPorts(t, ctx, mgr, zone, ports), + require.Equal(t, order0, survivingOrder(), "the re-added surviving family must keep the dual rule's position") }) }) + t.Run("tcpudproundtrip", func(t *testing.T) { + // A TCPUDP rule matches both transports. Backends with no both-transports form + // (iptables, pf, firewalld, wf, csf/apf's per-transport config lists) store it + // as a tcp row plus a udp row; nftables stores it as one `meta l4proto + // { tcp, udp }` row; ufw as one any-protocol tuple. GetRules reports whichever + // rows the backend actually holds, so the read-back is checked by coverage: the + // rows must cover the rule and none may widen it. Re-adding must not duplicate, + // and one remove must clear every transport. + const p uint16 = 3494 + rule := &Rule{Proto: TCPUDP, Port: p, Action: Accept} + match := func(r *Rule) bool { return r.Port == p && onProtocolAxis(r.Proto) } + + if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express a both-transports port rule") + } else { + require.NoError(t, err) + } + t.Cleanup(func() { + for _, r := range rulesOf(t, ctx, mgr, zone) { + if match(r) { + _ = mgr.RemoveRule(ctx, zone, r) + } + } + }) + + found := func() []*Rule { + var out []*Rule + for _, r := range rulesOf(t, ctx, mgr, zone) { + if match(r) { + out = append(out, r) + } + } + return out + } + + got := found() + require.NotEmpty(t, got, "the rule must read back") + require.True(t, rule.CoveredBy(got), "the stored rows must cover both transports, got %+v", got) + for _, r := range got { + require.True(t, rule.Covers(r), "a stored row must not widen the rule: %+v", r) + } + + // Re-adding the same rule is a no-op: every row it fans into dedups against + // what is already stored. + require.NoError(t, mgr.AddRule(ctx, zone, rule)) + require.Len(t, found(), len(got), "re-adding the rule must not duplicate its rows") + + // One remove clears both transports. + require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) + require.Empty(t, found(), "removing the rule must clear both transports") + }) + + t.Run("tcpudpsplitremove", func(t *testing.T) { + // The protocol analog of familyanysplitremove: removing ONE transport of a + // both-transports rule must leave the other in place. Backends that store the + // pair as two rows drop only the targeted row; nftables splits its single + // `meta l4proto { tcp, udp }` row and re-adds the survivor. + const p uint16 = 3495 + both := &Rule{Proto: TCPUDP, Port: p, Action: Accept} + tcp := &Rule{Proto: TCP, Port: p, Action: Accept} + udp := &Rule{Proto: UDP, Port: p, Action: Accept} + match := func(r *Rule) bool { return r.Port == p && onProtocolAxis(r.Proto) } + + coverage := func() (tcpCov, udpCov bool) { + for _, r := range rulesOf(t, ctx, mgr, zone) { + if !match(r) { + continue + } + switch r.Proto { + case TCPUDP: + tcpCov, udpCov = true, true + case TCP: + tcpCov = true + case UDP: + udpCov = true + } + } + return + } + clear := func() { + for _, r := range rulesOf(t, ctx, mgr, zone) { + if match(r) { + _ = mgr.RemoveRule(ctx, zone, r) + } + } + } + + if err := mgr.AddRule(ctx, zone, both); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express a both-transports port rule") + } else { + require.NoError(t, err) + } + t.Cleanup(clear) + if hasT, hasU := coverage(); !(hasT && hasU) { + clear() + t.Skipf("backend does not give this rule both-transport coverage (tcp=%v udp=%v)", hasT, hasU) + } + + // Remove TCP; UDP must survive. + if err := mgr.RemoveRule(ctx, zone, tcp); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express a single-transport removal of this rule") + } else { + require.NoError(t, err) + } + hasT, hasU := coverage() + require.False(t, hasT, "removing tcp must clear tcp coverage") + require.True(t, hasU, "removing tcp from a tcpudp rule must leave udp in place") + + // Opposite direction from a clean slate: remove UDP, TCP must survive. + clear() + require.NoError(t, mgr.AddRule(ctx, zone, both)) + if hasT, hasU := coverage(); !(hasT && hasU) { + t.Fatalf("re-adding the tcpudp rule must restore both transports (tcp=%v udp=%v)", hasT, hasU) + } + require.NoError(t, mgr.RemoveRule(ctx, zone, udp)) + hasT, hasU = coverage() + require.False(t, hasU, "removing udp must clear udp coverage") + require.True(t, hasT, "removing udp from a tcpudp rule must leave tcp in place") + }) + t.Run("diranysplitremove", func(t *testing.T) { // A DirAny rule applies in BOTH directions. On the chain backends it is stored // as an inbound rule plus a role-swapped outbound rule (two physical rows); on @@ -683,7 +809,8 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context } switch r.Direction { case DirAny: - // Merged back into the inbound frame: dport p from the host. + // A bidirectional row, stated in the inbound frame: dport p from + // the host. if addrEqual(r.Source, host) && soleDest() { return true, true } @@ -704,9 +831,11 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context }) t.Run("diranyroundtrip", func(t *testing.T) { - // A DirAny rule added and read back must round-trip as exactly one DirAny rule, - // and a second add of the same rule must be an idempotent no-op (the merged - // DirAny rule dedups a re-add) rather than doubling the underlying rows. + // A DirAny rule reads back as whatever the backend stores: one bidirectional + // line where its config has that form (csf.allow, apf's allow_hosts), otherwise + // an inbound row plus its role-swapped outbound row. Either way the rows must + // cover the rule and none may widen it, and a second add must be an idempotent + // no-op rather than doubling the rows. requireCap(t, caps.Output) rule := &Rule{Direction: DirAny, Source: "192.0.2.78", Action: Accept} if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) { @@ -716,28 +845,32 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context } t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) - matches := func() int { - n := 0 + matches := func() []*Rule { + var out []*Rule for _, r := range rulesOf(t, ctx, mgr, zone) { - if r.Direction == DirAny && addrEqual(r.Source, "192.0.2.78") { - n++ + if addrEqual(r.Source, "192.0.2.78") || addrEqual(r.Destination, "192.0.2.78") { + out = append(out, r) } } - return n + return out + } + got := matches() + require.True(t, rule.CoveredBy(got), "the stored rows must cover both directions, got %+v", got) + for _, r := range got { + require.True(t, rule.Covers(r), "a stored row must not widen the rule: %+v", r) } - require.Equal(t, 1, matches(), "a DirAny rule must read back as exactly one DirAny rule") - // A redundant add must not create a second copy. + // A redundant add must not create a second copy of any row. require.NoError(t, mgr.AddRule(ctx, zone, rule)) - require.Equal(t, 1, matches(), "re-adding an existing DirAny rule must be a no-op") + require.Len(t, matches(), len(got), "re-adding an existing DirAny rule must be a no-op") }) t.Run("diranyported", func(t *testing.T) { // A DirAny rule that is NOT a bare host — here a host + destination port — must // still round-trip: it fans out into an inbound (dport) rule and its role- - // swapped outbound (sport) twin, which collapse back to one DirAny rule on read. - // This exercises the fan-out path that a plain csf.allow/apf line (bare host) - // does not use. Skip where the backend cannot express the shape. + // swapped outbound (sport) twin, which together cover the rule on read. This + // exercises the fan-out path that a plain csf.allow/apf line (bare host) does + // not use. Skip where the backend cannot express the shape. requireCap(t, caps.Output) rule := &Rule{Direction: DirAny, Proto: TCP, Port: 22, Source: "192.0.2.80", Action: Accept} if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) { @@ -746,11 +879,11 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context require.NoError(t, err) } t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) - require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, caps.Output), - "a DirAny ported host rule must read back as one DirAny rule") + require.True(t, rule.CoveredBy(rulesOf(t, ctx, mgr, zone)), + "the stored rows must cover both directions of the ported host rule") require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) - require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, caps.Output), + require.False(t, rule.CoveredBy(rulesOf(t, ctx, mgr, zone)), "DirAny ported host rule still present after removal") }) @@ -805,6 +938,22 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}) }) + t.Run("reload", func(t *testing.T) { + // Every backend must survive an actual reload/apply with a managed rule in + // place: csf must ride out its restart lock, apf must be able to run + // `apf --restart`. The v6 variant below covers the backends that keep IPv6 + // rules in a separate file, but it is gated on ICMPv6 support — which csf and + // apf drop when their own IPv6 handling is off — so exercise Reload here for + // everyone with a plain IPv4-expressible rule. + r := &Rule{Proto: TCP, Port: 3530, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, r)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) }) + require.NoError(t, mgr.Reload(ctx), "reload must succeed with a managed rule present") + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), "rule missing after reload") + require.NoError(t, mgr.RemoveRule(ctx, zone, r)) + require.NoError(t, mgr.Reload(ctx), "reload must succeed after removing the rule") + }) + t.Run("reloadv6raw", func(t *testing.T) { requireCap(t, caps.ICMPv6) // An IPv6 rule a backend keeps in its own IPv6 rule file must survive an @@ -998,13 +1147,13 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context } }) - t.Run("natmergedfamilyremove", func(t *testing.T) { + t.Run("natfamilypairremove", func(t *testing.T) { requireCap(t, caps.NAT) - // A v4 masquerade and its v6 twin on the same interface differ only in Family, - // so GetNATRules collapses the pair into one FamilyAny rule (mergeNATFamilies). - // Removing that read-back rule must clear BOTH underlying rows. Regression for - // a NAT remove that stopped at the first match and left the IPv6 twin loaded - // (pf), mirroring the filter-side mergedfamilyremove probe. + // A v4 masquerade and its v6 twin on the same interface may be one row (nft's + // inet table, a pf rule with no af) or two. Removing every rule the backend + // reports must clear them all. Regression for a NAT remove that stopped at the + // first match and left the IPv6 twin loaded (pf), mirroring the filter-side + // familypairremove probe. v4 := &NATRule{Kind: Masquerade, Family: IPv4, Interface: "eth1"} v6 := &NATRule{Kind: Masquerade, Family: IPv6, Interface: "eth1"} added := 0 @@ -1017,15 +1166,15 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context added++ } if added < 2 { - t.Skip("backend does not express both families of an interface masquerade (no twin to merge)") + t.Skip("backend does not express both families of an interface masquerade") } t.Cleanup(func() { _ = mgr.RemoveNATRule(ctx, zone, v4) _ = mgr.RemoveNATRule(ctx, zone, v6) }) - // Remove every masquerade the backend reports for this interface (one merged - // FamilyAny rule, or one per family), then confirm none remain. + // Remove every masquerade the backend reports for this interface (one + // family-agnostic rule, or one per family), then confirm none remain. isMasq := func(r *NATRule) bool { return r.Kind == Masquerade && r.Interface == "eth1" } nats, err := mgr.GetNATRules(ctx, zone) require.NoError(t, err) @@ -1079,12 +1228,11 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context requireAscendingNumbers(t, managedNumbers(t, ctx, mgr, zone, []uint16{3000, 3001, 3002, 3003})) }) - t.Run("mergedmove", func(t *testing.T) { + t.Run("familyanymove", func(t *testing.T) { requireCap(t, caps.RuleOrdering) - // A v4/v6 twin collapses to one logical rule that occupies two physical rows. - // Moving it must relocate BOTH rows as a unit: on backends that map a merged - // position to a physical index, a naive move drags only one row and orphans a - // concrete-family twin (grows the chain, splits the merged rule). + // A v4 rule and its v6 twin may occupy two physical rows. Moving them with one + // FamilyAny target must relocate BOTH as a unit: a naive move drags only one + // row and orphans the twin at its old position. const port = 3510 v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept} v6 := &Rule{Family: IPv6, Proto: TCP, Port: port, Action: Accept} @@ -1108,25 +1256,34 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context _ = mgr.RemoveRule(ctx, zone, b) }) - // The pair merges, so the chain reads as two logical rules. - merged := findRule(t, ctx, mgr, zone, v4) - require.Equal(t, FamilyAny, merged.Family, "the v4/v6 pair should read back as one FamilyAny rule") + before := managedPorts(t, ctx, mgr, zone, []uint16{port}) + require.NotEmpty(t, before) + + // Move every row of the pair to the end with one FamilyAny target. The chain + // holds b plus the pair's rows, so a position past the last row appends. A + // backend that can store one family-agnostic row (nft's inet table) re-adds the + // target as that single row rather than the two it replaced, so the row count + // may shrink — what must hold is that b now leads, no row of the pair is left + // behind it, and both families still have coverage. + twin := &Rule{Family: FamilyAny, Proto: TCP, Port: port, Action: Accept} + require.NoError(t, mgr.MoveRule(ctx, zone, twin, len(before)+2)) - // Move the merged twin to the end (position 2, after b). - require.NoError(t, mgr.MoveRule(ctx, zone, merged, 2)) order := managedPorts(t, ctx, mgr, zone, []uint16{port, 3511}) - require.Equal(t, []uint16{3511, port}, order, - "the merged twin must move as one unit to the end; an extra port entry means a concrete-family row was orphaned") - require.Equal(t, FamilyAny, findRule(t, ctx, mgr, zone, v4).Family, - "the moved twin must stay merged, not leave a concrete-family orphan") + require.NotEmpty(t, order) + require.Equal(t, uint16(3511), order[0], + "b must now be first: every row of the pair moved past it") + for _, p := range order[1:] { + require.EqualValues(t, port, p, "no row of the pair may be left before b") + } + require.True(t, twin.CoveredBy(rulesOf(t, ctx, mgr, zone)), + "both families must survive the move") }) - t.Run("mergedinsertposition", func(t *testing.T) { + t.Run("insertposition", func(t *testing.T) { requireCap(t, caps.RuleOrdering) - // GetRules numbers logical (merged) rules, but the chain holds physical rows. - // Inserting before a rule's reported Number must land at that logical position: - // with merged pairs preceding the target, a naive position-1 against physical - // rows skews the placement by the number of absorbed twins. + // GetRules reports one rule per stored row, each with the Number of its own + // position. Inserting before a rule's reported Number must land exactly there, + // whatever rows precede it. a4 := &Rule{Family: IPv4, Proto: TCP, Port: 3520, Action: Accept} a6 := &Rule{Family: IPv6, Proto: TCP, Port: 3520, Action: Accept} b4 := &Rule{Family: IPv4, Proto: TCP, Port: 3521, Action: Accept} @@ -1141,7 +1298,7 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context added++ } if added < 4 { - t.Skip("backend does not express both families of both bare-port pairs (no merged twins to skew placement)") + t.Skip("backend does not express both families of both bare-port pairs") } c := &Rule{Family: IPv4, Proto: TCP, Port: 3522, Action: Accept} require.NoError(t, mgr.AddRule(ctx, zone, c)) @@ -1151,19 +1308,18 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context } }) - // Two merged pairs plus c read back as three logical rules. Read c's Number - // (its logical position, whatever the backend's add order) and insert d there. + // Read c's Number (its position, whatever the backend's add order) and insert + // d there. c is IPv4-only, so it reads back as exactly one rule. nums := managedNumbers(t, ctx, mgr, zone, []uint16{3522}) - require.Len(t, nums, 1, "c should read back as one logical rule") + require.Len(t, nums, 1, "c is IPv4-only and reads back as one rule") cNum := nums[0] d := &Rule{Family: IPv4, Proto: TCP, Port: 3523, Action: Accept} require.NoError(t, mgr.InsertRule(ctx, zone, cNum, d)) t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, d) }) - // d must land immediately before c — not skewed early into the merged pairs. + // d must land immediately before c. order := managedPorts(t, ctx, mgr, zone, []uint16{3520, 3521, 3522, 3523}) - require.Len(t, order, 4, "the two pairs, c and d must read back as four logical rules") var ci int for i, p := range order { if p == 3522 { @@ -1171,7 +1327,7 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context } } require.Greater(t, ci, 0, "d must be inserted before c, so c is not first") - require.Equal(t, uint16(3523), order[ci-1], "d must land immediately before c, after both merged pairs") + require.Equal(t, uint16(3523), order[ci-1], "d must land immediately before c") }) // --- NAT ordering --------------------------------------------------------- diff --git a/iptables_linux.go b/iptables_linux.go index c8c8c8e..823b34f 100644 --- a/iptables_linux.go +++ b/iptables_linux.go @@ -13,28 +13,24 @@ import ( "strings" "github.com/anmitsu/go-shlex" - dbus "github.com/coreos/go-systemd/dbus" ) const ( - // IPTablesType is the manager type string for the iptables backend. - IPTablesType = "iptables" // IPTablesNoSave is the error text returned when no iptables save path is found. IPTablesNoSave = "unable to find iptables save path" // IPTablesNoService is the error text returned when no iptables service is found. IPTablesNoService = "unable to find iptables service" ) -// IPTables manages filter and NAT rules through iptables save files and their systemd units. +// IPTables manages filter and NAT rules through iptables save files and the service that restores them. type IPTables struct { - Conn *dbus.Conn IP4Service string IP4Path string IP6Path string IP6Service string // IPSetPath and IPSetService describe the optional ipset persistence // mechanism detected for this host. IPSetPath is the save file the sets are - // written to so a reboot restores them; IPSetService is the unit that restores + // written to so a reboot restores them; IPSetService is the service that restores // it before the rules load. Both are empty when no mechanism is installed, in // which case address sets are created live but not persisted across a reboot. IPSetPath string @@ -44,36 +40,37 @@ type IPTables struct { rulePrefix string } -// iptLayout names the save-file paths and systemd units a supported iptables -// packaging uses. The Debian layout carries the same unit for both families -// (netfilter-persistent.service restores both rules.v4 and rules.v6), so -// ip4Service and ip6Service are equal there. +// iptLayout names the save-file paths and restore services a supported iptables +// packaging uses. The Debian layout carries the same service for both families +// (netfilter-persistent restores both rules.v4 and rules.v6), so ip4Service and +// ip6Service are equal there. type iptLayout struct { ip4Path, ip6Path string ip4Service, ip6Service string - // ipsetPath is the save file the ipset restore 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 is the save file the ipset restore service reads on boot, and + // ipsetService is the service that restores it before the rules service + // loads the -m set rules that reference the sets. Persisting sets is optional + // (a missing mechanism is not fatal, unlike a missing rules save file), so + // these describe the packaging's convention; NewIPTables confirms the + // mechanism is installed. ipsetPath, ipsetService string // ipsetPlugin, when set, is a glob whose presence proves the restore mechanism // is installed. The Debian layout persists sets through a netfilter-persistent - // plugin rather than a dedicated unit, so its absence means the saved file + // plugin rather than a dedicated service, so its absence means the saved file // would never be restored and the sets are left live-only. ipsetPlugin string } // probeDebianLayout reports the Debian/Ubuntu iptables-persistent layout // (/etc/iptables/rules.v4 and rules.v6, both restored by the single -// netfilter-persistent.service) if its save files are both present under +// 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", + ip4Service: "netfilter-persistent", ip6Service: "netfilter-persistent", + ipsetPath: "/etc/iptables/ipsets", ipsetService: "netfilter-persistent", ipsetPlugin: "/usr/share/netfilter-persistent/plugins.d/*ipset*", } if _, err := os.Stat(root + l.ip4Path); err != nil { @@ -86,8 +83,8 @@ func probeDebianLayout(root string) (l iptLayout, ok bool) { } // probeRHELLayout reports the RHEL/iptables-services layout -// (/etc/sysconfig/iptables, iptables.service/ip6tables.service) if its save -// files are both present under root. It does not check root itself, so a +// (/etc/sysconfig/iptables and ip6tables, restored by the iptables and +// ip6tables services) if its save files are both present under root. It does // caller must have already confirmed the RHEL v4 path exists before treating // an incomplete pair (ok=false) as fatal rather than falling through to // another layout. root is prepended to both paths so tests can point the @@ -95,8 +92,8 @@ func probeDebianLayout(root string) (l iptLayout, ok bool) { func probeRHELLayout(root string) (l iptLayout, ok bool) { l = iptLayout{ ip4Path: "/etc/sysconfig/iptables", ip6Path: "/etc/sysconfig/ip6tables", - ip4Service: "iptables.service", ip6Service: "ip6tables.service", - ipsetPath: "/etc/sysconfig/ipset", ipsetService: "ipset.service", + ip4Service: "iptables", ip6Service: "ip6tables", + ipsetPath: "/etc/sysconfig/ipset", ipsetService: "ipset", } if _, err := os.Stat(root + l.ip4Path); err != nil { return iptLayout{}, false @@ -107,7 +104,7 @@ func probeRHELLayout(root string) (l iptLayout, ok bool) { return l, true } -// NewIPTables creates an iptables manager, detecting the save-file layout and confirming its systemd units are enabled. +// NewIPTables creates an iptables manager, detecting the save-file layout and confirming its restore services are enabled. func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) { ipt := new(IPTables) ipt.rulePrefix = rulePrefix @@ -133,29 +130,21 @@ func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) { } ipt.IP4Path, ipt.IP6Path = layout.ip4Path, layout.ip6Path - // Connect to systemd dbus interface. - conn, err := dbus.NewWithContext(ctx) - if err != nil { - return nil, fmt.Errorf("failed to connect to systemd: %s", err) - } - ipt.Conn = conn - - // Find the systemd service for loading iptables rules and confirm it was loaded. + // Confirm the service that restores the rules is enabled, under whatever + // init system the host uses. ipt.IP4Service = layout.ip4Service - if err := ipt.requireUnitEnabled(ctx, ipt.IP4Service); err != nil { - ipt.Conn.Close() - return nil, err + if !serviceEnabled(ctx, ipt.IP4Service) { + return nil, errors.New(IPTablesNoService) } // If ip6tables service is missing, we do not want to modify iptables // as we do not know what may be used for the firewall. Skip the redundant - // check when it is the same unit already confirmed enabled above (the - // Debian layout uses one unit for both families). + // check when it is the same service already confirmed enabled above (the + // Debian layout uses one service for both families). ipt.IP6Service = layout.ip6Service if ipt.IP6Service != ipt.IP4Service { - if err := ipt.requireUnitEnabled(ctx, ipt.IP6Service); err != nil { - ipt.Conn.Close() - return nil, err + if !serviceEnabled(ctx, ipt.IP6Service) { + return nil, errors.New(IPTablesNoService) } } @@ -167,44 +156,6 @@ func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) { return ipt, nil } -// 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 "", false, err - } - for _, uf := range files { - if filepath.Base(uf.Path) == service { - return uf.Type, true, nil - } - } - return "", false, nil -} - // Type returns the manager type. func (f *IPTables) Type() string { return IPTablesType @@ -954,33 +905,34 @@ func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) 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 the coalesced rules; GetRules assigns Number once both save files are + // read (the other callers use the result only for dedup and never read Number). return coalesceLoggedRules(perLine), nil } // GetRules returns the existing filter rules from the zone. func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + // Each save-file line is its own rule: iptables pins one family (by which file + // holds it), one transport and one direction (by chain) per line, so nothing here + // spans two of anything and nothing is collapsed. v4, err := f.parseFilterFile(f.IP4Path, IPv4) if err != nil { return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err) } - 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) } + + // Number each family's chains independently: the two families are separate + // rulesets in separate save files, so an IPv6 rule's InsertRule/MoveRule position + // counts only the ip6tables chain it lives in. Numbering the concatenation instead + // would offset every IPv6 rule by the IPv4 chain's length. + numberByDirection(v4) + numberByDirection(v6) + + rules = append(rules, v4...) rules = append(rules, v6...) - - // 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 } @@ -1003,6 +955,20 @@ func (f *IPTables) applyRuleFiles(r *Rule, prepare func(string, *Rule) (*atomicF return nil } + // A TCPUDP rule fans out into a tcp row plus a udp row, each marshalled into + // its own line in the same chain of the same family file(s). iptables has no + // both-transports match, so this split runs after the direction fan-out and + // before the family split; expandProtocols returns a single element for a + // concrete-protocol rule, so this never recurses more than once. + if subs := expandProtocols(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.applyRuleFiles(sub, prepare); err != nil { + return err + } + } + return nil + } + // Resolve the family, letting an ICMP/ICMPv6 protocol pin it: `-p icmp` // belongs only in the IPv4 file and `-p icmpv6` only in the IPv6 file. family := r.impliedFamily() @@ -1122,6 +1088,7 @@ func iptChainForDirection(d Direction) string { // LOG line paired with its action is one logical rule beginning at the LOG line, // while an orphan LOG line (no matching action after it) begins none. The // returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and +// prepareMoveRuleFile can translate a caller position into a physical line. func (f *IPTables) logicalStarts(rules []*Rule) []int { starts := make([]int, len(rules)) pos := 0 @@ -1149,6 +1116,16 @@ func (f *IPTables) logicalStarts(rules []*Rule) []int { return starts } +// chainStarts maps each physical `-A` line of chain (in file order) to the 1-based +// Number GetRules reports for the logical rule that begins there, or 0 for a line +// that begins no logical rule — an action line coalesced into a preceding LOG line, +// or a dropped orphan LOG line. Every other line is its own rule: iptables stores one +// family, one transport and one direction per line, so a rule's Number is its line's +// rank within its chain once LOG pairs are folded. +func (f *IPTables) chainStarts(lines []string, chain string) []int { + return f.logicalStarts(f.chainRules(lines, chain)) +} + // addrArgs encodes a source or destination match. dir is "src" or "dst". An // IP/CIDR uses `-s`/`-d`; a non-address token names an ipset, matched with // `-m set --match-set `. A leading "!" negation is emitted before the @@ -1258,6 +1235,12 @@ func (f *IPTables) stateValue(s ConnState) string { // up to but not including the `-j `), including any rate/connection // limit and the identifying comment. MarshalRule and the LOG-line encoder share func (f *IPTables) marshalMatches(r *Rule) ([]string, error) { + // A TCPUDP rule has no single-line iptables form; it must be fanned out into a + // tcp row and a udp row before reaching this row-level marshaller. Assert the + // caller already expanded it rather than emit an invalid `-p tcpudp` line. + if err := r.CheckExpandedProtocol(); err != nil { + return nil, err + } if err := iptablesRuleValid(r); err != nil { return nil, err } @@ -1469,16 +1452,17 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int) // diverge from the per-direction numbering GetRules reports. expectedChain := iptChainForDirection(r.Direction) - // Precompute the 1-based logical position each in-chain line begins, mirroring - // GetRules' numbering (a LOG+action pair is one logical rule, an orphan LOG - // line is none). Indexing this as the write pass scans keeps the insert aligned - // with the position GetRules reports and never splits a logged rule's two lines. + // Precompute the 1-based Number each in-chain line begins, mirroring GetRules' + // numbering (a LOG+action pair is one logical rule, an orphan LOG line is none). + // Indexing this as the write pass scans keeps the insert aligned with the + // position GetRules reports and never splits a logged rule's two lines or a + // fanned-out tcp/udp pair. allLines, err := f.readAllLines(filePath) if err != nil { af.Abort() return nil, err } - chainStarts := f.logicalStarts(f.chainRules(allLines, expectedChain)) + chainStarts := f.chainStarts(allLines, expectedChain) chainIdx := 0 scanner := bufio.NewScanner(fd) @@ -1546,7 +1530,9 @@ func (f *IPTables) InsertRule(ctx context.Context, zoneName string, position int // 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. +// not present. It coalesces LOG+action lines for logged rules. Family is not +// compared: a save file holds exactly one family, so the caller has already scoped +// the search by choosing which file to read. func (f *IPTables) extractRuleLines(lines []string, r *Rule) ([]string, int, error) { var result []string var resultIdx int @@ -1680,10 +1666,11 @@ func (f *IPTables) prepareMoveRuleFile(filePath string, r *Rule, position int) ( // 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)) + // Precompute the 1-based Number each in-chain line begins over the post-removal + // lines, mirroring GetRules' numbering so the re-inserted rule + // lands at the requested position and never between a LOG line and its action + // or between a fanned-out tcp/udp pair. + chainStarts := f.chainStarts(without, expectedChain) chainIdx := 0 out := make([]string, 0, len(without)+len(extracted)) @@ -2021,28 +2008,30 @@ func (f *IPTables) natRulesInFile(path string) ([]*NATRule, error) { } rules = append(rules, nr) } - // Return the rules; GetNATRules assigns Number after merging families across the - // two save files (the other callers use the result only for EqualBase dedup). + // Return the rules; GetNATRules assigns Number per family (the other callers use + // the result only for dedup and never read Number). return rules, nil } // GetNATRules returns the existing NAT rules from the zone. func (f *IPTables) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { + // Each save-file line is its own NAT rule, pinned to the family of the file it + // lives in. Number each family's nat chains independently, as GetRules does for + // the filter chains, so a rule's Number matches the InsertNATRule position within + // the chain it actually lives in. v4, err := f.natRulesInFile(f.IP4Path) if err != nil { return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err) } - 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) } + numberNATByChain(v4) + numberNATByChain(v6) + rules = append(rules, v4...) 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 rules, nil } // natChain returns the nat-table chain a NAT rule belongs in. @@ -2373,6 +2362,328 @@ func (f *IPTables) RemoveNATRule(ctx context.Context, zoneName string, r *NATRul return nil } +// parsePolicyLine decodes a `:CHAIN POLICY [counters]` chain declaration. +func (f *IPTables) parsePolicyLine(line string) (chain string, action Action, ok bool) { + t := strings.TrimSpace(line) + if !strings.HasPrefix(t, ":") { + return "", 0, false + } + fields := strings.Fields(t) + if len(fields) < 2 { + return "", 0, false + } + switch fields[1] { + case "ACCEPT": + action = Accept + case "DROP": + action = Drop + default: + return "", 0, false + } + return strings.TrimPrefix(fields[0], ":"), action, true +} + +// policyFromFile reads the INPUT/OUTPUT/FORWARD chain policies from an +// iptables-save file. A direction whose chain line is absent is reported as +// ActionInvalid. +func (f *IPTables) policyFromFile(path string) (*DefaultPolicy, error) { + lines, err := f.readAllLines(path) + if err != nil { + return nil, err + } + p := &DefaultPolicy{} + // Only the *filter table carries the input/output/forward policy. The other + // tables (*nat, *mangle, *raw, ...) declare their own :INPUT/:OUTPUT built-in + // chains — *nat's is always ACCEPT (iptables rejects any other policy there), + // while *mangle/*raw can carry any policy but are not filtering tables — and + // iptables-save emits them after *filter, so scanning table-agnostically would + // let one of those chains shadow a hardened filter policy (e.g. report + // Input=Accept when filter INPUT is DROP). Track the table. + inFilter := false + for _, raw := range lines { + if t := strings.TrimSpace(raw); strings.HasPrefix(t, "*") { + inFilter = t == "*filter" + continue + } + if !inFilter { + continue + } + chain, action, ok := f.parsePolicyLine(raw) + if !ok { + continue + } + switch chain { + case "INPUT": + p.Input = action + case "OUTPUT": + p.Output = action + case "FORWARD": + p.Forward = action + } + } + return p, nil +} + +// GetDefaultPolicy returns the default action applied to packets that match no rule. +func (f *IPTables) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + v4, err := f.policyFromFile(f.IP4Path) + if err != nil { + return nil, err + } + v6, err := f.policyFromFile(f.IP6Path) + if err != nil { + return nil, err + } + // SetDefaultPolicy writes both families identically, so on a host this library + // manages they always agree. A divergence means the IPv4 and IPv6 chain + // policies were set out of band and there is no single policy to report. + if *v4 != *v6 { + return nil, fmt.Errorf("iptables default policy differs between IPv4 (%+v) and IPv6 (%+v)", *v4, *v6) + } + return v4, nil +} + +// setPolicyFile rewrites the chain declaration lines in an iptables-save +// file for the directions named in policy, preserving the counter slots. +func (f *IPTables) setPolicyFile(path string, policy *DefaultPolicy) error { + lines, err := f.readAllLines(path) + if err != nil { + return err + } + updated := make([]string, len(lines)) + // Only rewrite policy lines inside the *filter table; the other tables + // (nat/mangle/raw/...) declare their own built-in chains — nat's must stay + // ACCEPT (iptables rejects any other policy there), and mangle/raw are not + // filtering tables regardless — so leave them all untouched. + inFilter := false + for i, raw := range lines { + if t := strings.TrimSpace(raw); strings.HasPrefix(t, "*") { + inFilter = t == "*filter" + updated[i] = raw + continue + } + chain, _, ok := f.parsePolicyLine(raw) + if !ok || !inFilter { + updated[i] = raw + continue + } + var action Action + switch chain { + case "INPUT": + action = policy.Input + case "OUTPUT": + action = policy.Output + case "FORWARD": + action = policy.Forward + default: + updated[i] = raw + continue + } + if action == ActionInvalid { + updated[i] = raw + continue + } + fields := strings.Fields(raw) + counters := "[0:0]" + if len(fields) >= 3 { + counters = fields[2] + } + updated[i] = fmt.Sprintf("%s %s %s", fields[0], strings.ToUpper(action.String()), counters) + } + return f.writeAllLines(path, updated) +} + +// SetDefaultPolicy sets the default action for the directions named in policy. +func (f *IPTables) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + if policy == nil { + return fmt.Errorf("policy cannot be nil") + } + for _, action := range []Action{policy.Input, policy.Output, policy.Forward} { + if action == Reject { + return fmt.Errorf("iptables chain policy may only be accept or drop") + } + } + for _, path := range []string{f.IP4Path, f.IP6Path} { + if err := f.setPolicyFile(path, policy); err != nil { + return err + } + } + return nil +} + +// --- address sets (ipset) --------------------------------------------------- + +// ipsetParseType reads the family and type out of an ipset `create` line's +// trailing options. +func (f *IPTables) ipsetParseType(fields []string) (Family, SetType) { + family := IPv4 + t := SetHashIP + for i := 2; i < len(fields); i++ { + switch fields[i] { + case "hash:net": + t = SetHashNet + case "hash:ip": + t = SetHashIP + case "family": + if i+1 < len(fields) && fields[i+1] == "inet6" { + family = IPv6 + } + } + } + return family, t +} + +// GetAddressSets returns the address sets managed by this backend. +func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + out, err := runCommand(ctx, "ipset", "save") + if err != nil { + // ipset not installed, or no sets: nothing to report. + return nil, nil + } + sets := map[string]*AddressSet{} + var names []string + for _, line := range out { + fields := strings.Fields(line) + if len(fields) >= 3 && fields[0] == "create" { + family, t := f.ipsetParseType(fields) + sets[fields[1]] = &AddressSet{Name: fields[1], Family: family, Type: t} + names = append(names, fields[1]) + } + } + for _, line := range out { + fields := strings.Fields(line) + if len(fields) == 3 && fields[0] == "add" { + if set, ok := sets[fields[1]]; ok { + set.Entries = append(set.Entries, fields[2]) + } + } + } + result := make([]*AddressSet, 0, len(names)) + for _, n := range names { + result = append(result, sets[n]) + } + return result, nil +} + +// GetAddressSet returns a single address set by name, or an error if it does not exist. +func (f *IPTables) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + sets, err := f.GetAddressSets(ctx) + if err != nil { + return nil, err + } + for _, s := range sets { + if s.Name == name { + return s, nil + } + } + return nil, fmt.Errorf("address set %q not found", name) +} + +// ipsetTypeSpec renders the ipset type keyword and family option for a set. +func (f *IPTables) ipsetTypeSpec(family Family, t SetType) string { + spec := t.String() + fam := "inet" + if family == IPv6 { + fam = "inet6" + } + return spec + " family " + fam +} + +// persistIPSets writes the live ipsets into the layout's save file so a reboot +// restores them before the iptables rules that reference them. When the restore +// unit is installed but not enabled it is enabled first, so it runs on boot. +// When no persistence mechanism is present the sets are left live-only and a +// warning is logged rather than returning an error — the set itself was already +// created live, and the caller asked to add a set, not to guarantee reboot +// persistence the host cannot provide. +func (f *IPTables) persistIPSets(ctx context.Context) error { + if f.IPSetPath == "" { + log.Printf("firewall: address sets are live-only; no ipset persistence mechanism found, they will not survive a reboot") + return nil + } + // Ensure the restore unit runs on boot before the rules unit loads. + if f.IPSetService != "" { + if err := enableService(ctx, f.IPSetService); err != nil { + return err + } + } + // Save every live set (foreign sets included: the library persists the actual + // firewall state) into the file the restore unit reads on boot. + out, err := runCommand(ctx, "ipset", "save") + if err != nil { + return err + } + data := strings.Join(out, "\n") + if data != "" { + data += "\n" + } + return writeConfigFile(f.IPSetPath, []byte(data), 0600) +} + +// AddAddressSet creates an address set; adding a set that already exists by name is a no-op. +func (f *IPTables) AddAddressSet(ctx context.Context, set *AddressSet) error { + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + // -exist makes create idempotent (re-create over an existing set). + family := set.Family + if family == FamilyAny { + family = IPv4 + } + args := []string{"create", set.Name} + args = append(args, strings.Fields(f.ipsetTypeSpec(family, set.Type))...) + args = append(args, "-exist") + if _, err := runCommand(ctx, "ipset", args...); err != nil { + return err + } + for _, entry := range set.Entries { + if _, err := runCommand(ctx, "ipset", "add", set.Name, entry, "-exist"); err != nil { + return err + } + } + return f.persistIPSets(ctx) +} + +// RemoveAddressSet removes an address set by name. +func (f *IPTables) RemoveAddressSet(ctx context.Context, name string) error { + if _, err := runCommand(ctx, "ipset", "flush", name); err != nil { + // A missing set is a no-op; any other flush failure (permission denied, + // set busy) is real and must be surfaced rather than silently proceeding + // to destroy. + if !strings.Contains(err.Error(), "does not exist") { + return err + } + } + _, err := runCommand(ctx, "ipset", "destroy", name) + // A set that was already gone makes removal idempotent. Every other failure — + // notably "Set cannot be destroyed: it is in use by a kernel component" when a + // live rule still references the set — is real and must be surfaced rather than + // reported as success while the set remains. + if err != nil && !strings.Contains(err.Error(), "does not exist") { + return err + } + return f.persistIPSets(ctx) +} + +// AddAddressSetEntry adds an entry to the named set. +func (f *IPTables) AddAddressSetEntry(ctx context.Context, name, entry string) error { + if _, err := runCommand(ctx, "ipset", "add", name, entry, "-exist"); err != nil { + return err + } + return f.persistIPSets(ctx) +} + +// RemoveAddressSetEntry removes an entry from the named set. +func (f *IPTables) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + _, err := runCommand(ctx, "ipset", "del", name, entry, "-exist") + // A missing entry (or missing set) makes removal idempotent; any other failure + // is real. Persist the resulting set state on success or a no-op removal. + if err != nil && !strings.Contains(err.Error(), "does not exist") { + return err + } + return f.persistIPSets(ctx) +} + // Backup captures the current filter and NAT rules managed by this backend. func (f *IPTables) Backup(ctx context.Context, zoneName string) (*Backup, error) { rules, err := f.GetRules(ctx, zoneName) @@ -2646,11 +2957,15 @@ func (f *IPTables) Restore(ctx context.Context, zoneName string, backup *Backup) if c.Family == FamilyAny { c.Family = fam } - rl, err := f.marshalRuleLines(&c) - if err != nil { - return err + // A TCPUDP rule has no single-line iptables form; fan it out into a tcp + // row and a udp row before marshalling. + for _, sub := range expandProtocols(&c) { + rl, err := f.marshalRuleLines(sub) + if err != nil { + return err + } + ruleLines = append(ruleLines, rl...) } - ruleLines = append(ruleLines, rl...) } var natLines []string @@ -2680,383 +2995,22 @@ func (f *IPTables) Restore(ctx context.Context, zoneName string, backup *Backup) 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 -// iptables-save file. A direction whose chain line is absent is reported as -// ActionInvalid. -func (f *IPTables) policyFromFile(path string) (*DefaultPolicy, error) { - lines, err := f.readAllLines(path) - if err != nil { - return nil, err - } - p := &DefaultPolicy{} - // Only the *filter table carries the input/output/forward policy. The other - // tables (*nat, *mangle, *raw, ...) declare their own :INPUT/:OUTPUT built-in - // chains — *nat's is always ACCEPT (iptables rejects any other policy there), - // while *mangle/*raw can carry any policy but are not filtering tables — and - // iptables-save emits them after *filter, so scanning table-agnostically would - // let one of those chains shadow a hardened filter policy (e.g. report - // Input=Accept when filter INPUT is DROP). Track the table. - inFilter := false - for _, raw := range lines { - if t := strings.TrimSpace(raw); strings.HasPrefix(t, "*") { - inFilter = t == "*filter" - continue - } - if !inFilter { - continue - } - chain, action, ok := f.parsePolicyLine(raw) - if !ok { - continue - } - switch chain { - case "INPUT": - p.Input = action - case "OUTPUT": - p.Output = action - case "FORWARD": - p.Forward = action - } - } - return p, nil -} - -// GetDefaultPolicy returns the default action applied to packets that match no rule. -func (f *IPTables) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { - v4, err := f.policyFromFile(f.IP4Path) - if err != nil { - return nil, err - } - v6, err := f.policyFromFile(f.IP6Path) - if err != nil { - return nil, err - } - // SetDefaultPolicy writes both families identically, so on a host this library - // manages they always agree. A divergence means the IPv4 and IPv6 chain - // policies were set out of band and there is no single policy to report. - if *v4 != *v6 { - return nil, fmt.Errorf("iptables default policy differs between IPv4 (%+v) and IPv6 (%+v)", *v4, *v6) - } - return v4, nil -} - -// setPolicyFile rewrites the chain declaration lines in an iptables-save -// file for the directions named in policy, preserving the counter slots. -func (f *IPTables) setPolicyFile(path string, policy *DefaultPolicy) error { - lines, err := f.readAllLines(path) - if err != nil { - return err - } - updated := make([]string, len(lines)) - // Only rewrite policy lines inside the *filter table; the other tables - // (nat/mangle/raw/...) declare their own built-in chains — nat's must stay - // ACCEPT (iptables rejects any other policy there), and mangle/raw are not - // filtering tables regardless — so leave them all untouched. - inFilter := false - for i, raw := range lines { - if t := strings.TrimSpace(raw); strings.HasPrefix(t, "*") { - inFilter = t == "*filter" - updated[i] = raw - continue - } - chain, _, ok := f.parsePolicyLine(raw) - if !ok || !inFilter { - updated[i] = raw - continue - } - var action Action - switch chain { - case "INPUT": - action = policy.Input - case "OUTPUT": - action = policy.Output - case "FORWARD": - action = policy.Forward - default: - updated[i] = raw - continue - } - if action == ActionInvalid { - updated[i] = raw - continue - } - fields := strings.Fields(raw) - counters := "[0:0]" - if len(fields) >= 3 { - counters = fields[2] - } - updated[i] = fmt.Sprintf("%s %s %s", fields[0], strings.ToUpper(action.String()), counters) - } - return f.writeAllLines(path, updated) -} - -// SetDefaultPolicy sets the default action for the directions named in policy. -func (f *IPTables) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { - if policy == nil { - return fmt.Errorf("policy cannot be nil") - } - for _, action := range []Action{policy.Input, policy.Output, policy.Forward} { - if action == Reject { - return fmt.Errorf("iptables chain policy may only be accept or drop") - } - } - for _, path := range []string{f.IP4Path, f.IP6Path} { - if err := f.setPolicyFile(path, policy); err != nil { - return err - } - } - return nil -} - -// --- address sets (ipset) --------------------------------------------------- - -// ipsetParseType reads the family and type out of an ipset `create` line's -// trailing options. -func (f *IPTables) ipsetParseType(fields []string) (Family, SetType) { - family := IPv4 - t := SetHashIP - for i := 2; i < len(fields); i++ { - switch fields[i] { - case "hash:net": - t = SetHashNet - case "hash:ip": - t = SetHashIP - case "family": - if i+1 < len(fields) && fields[i+1] == "inet6" { - family = IPv6 - } - } - } - return family, t -} - -// GetAddressSets returns the address sets managed by this backend. -func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { - out, err := runCommand(ctx, "ipset", "save") - if err != nil { - // ipset not installed, or no sets: nothing to report. - return nil, nil - } - sets := map[string]*AddressSet{} - var names []string - for _, line := range out { - fields := strings.Fields(line) - if len(fields) >= 3 && fields[0] == "create" { - family, t := f.ipsetParseType(fields) - sets[fields[1]] = &AddressSet{Name: fields[1], Family: family, Type: t} - names = append(names, fields[1]) - } - } - for _, line := range out { - fields := strings.Fields(line) - if len(fields) == 3 && fields[0] == "add" { - if set, ok := sets[fields[1]]; ok { - set.Entries = append(set.Entries, fields[2]) - } - } - } - result := make([]*AddressSet, 0, len(names)) - for _, n := range names { - result = append(result, sets[n]) - } - return result, nil -} - -// GetAddressSet returns a single address set by name, or an error if it does not exist. -func (f *IPTables) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { - sets, err := f.GetAddressSets(ctx) - if err != nil { - return nil, err - } - for _, s := range sets { - if s.Name == name { - return s, nil - } - } - return nil, fmt.Errorf("address set %q not found", name) -} - -// ipsetTypeSpec renders the ipset type keyword and family option for a set. -func (f *IPTables) ipsetTypeSpec(family Family, t SetType) string { - spec := t.String() - fam := "inet" - if family == IPv6 { - fam = "inet6" - } - return spec + " family " + fam -} - -// AddAddressSet creates an address set; adding a set that already exists by name is a no-op. -func (f *IPTables) AddAddressSet(ctx context.Context, set *AddressSet) error { - if set == nil || set.Name == "" { - return fmt.Errorf("an address set requires a name") - } - // -exist makes create idempotent (re-create over an existing set). - family := set.Family - if family == FamilyAny { - family = IPv4 - } - args := []string{"create", set.Name} - args = append(args, strings.Fields(f.ipsetTypeSpec(family, set.Type))...) - args = append(args, "-exist") - if _, err := runCommand(ctx, "ipset", args...); err != nil { - return err - } - for _, entry := range set.Entries { - if _, err := runCommand(ctx, "ipset", "add", set.Name, entry, "-exist"); err != nil { - return err - } - } - return f.persistIPSets(ctx) -} - -// RemoveAddressSet removes an address set by name. -func (f *IPTables) RemoveAddressSet(ctx context.Context, name string) error { - if _, err := runCommand(ctx, "ipset", "flush", name); err != nil { - // A missing set is a no-op; any other flush failure (permission denied, - // set busy) is real and must be surfaced rather than silently proceeding - // to destroy. - if !strings.Contains(err.Error(), "does not exist") { - return err - } - } - _, err := runCommand(ctx, "ipset", "destroy", name) - // A set that was already gone makes removal idempotent. Every other failure — - // notably "Set cannot be destroyed: it is in use by a kernel component" when a - // live rule still references the set — is real and must be surfaced rather than - // reported as success while the set remains. - if err != nil && !strings.Contains(err.Error(), "does not exist") { - return err - } - return f.persistIPSets(ctx) -} - -// AddAddressSetEntry adds an entry to the named set. -func (f *IPTables) AddAddressSetEntry(ctx context.Context, name, entry string) error { - if _, err := runCommand(ctx, "ipset", "add", name, entry, "-exist"); err != nil { - return err - } - return f.persistIPSets(ctx) -} - -// RemoveAddressSetEntry removes an entry from the named set. -func (f *IPTables) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { - _, err := runCommand(ctx, "ipset", "del", name, entry, "-exist") - // A missing entry (or missing set) makes removal idempotent; any other failure - // is real. Persist the resulting set state on success or a no-op removal. - if err != nil && !strings.Contains(err.Error(), "does not exist") { - return err - } - return f.persistIPSets(ctx) -} - -// persistIPSets writes the live ipsets into the layout's save file so a reboot -// restores them before the iptables rules that reference them. When the restore -// unit is installed but not enabled it is enabled first, so it runs on boot. -// When no persistence mechanism is present the sets are left live-only and a -// warning is logged rather than returning an error — the set itself was already -// created live, and the caller asked to add a set, not to guarantee reboot -// persistence the host cannot provide. -func (f *IPTables) persistIPSets(ctx context.Context) error { - if f.IPSetPath == "" { - log.Printf("firewall: address sets are live-only; no ipset persistence mechanism found, they will not survive a reboot") - return nil - } - // Ensure the restore unit runs on boot before the rules unit loads. - if f.IPSetService != "" { - if err := 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. +// Reload restarts the restore service(s) to activate new rules. func (f *IPTables) Reload(ctx context.Context) error { - if err := f.restartUnit(ctx, f.IP4Service); err != nil { + if err := restartService(ctx, f.IP4Service); err != nil { return err } - // The Debian layout restores both families from one unit - // (netfilter-persistent.service); restarting it twice is redundant. + // The Debian layout restores both families from one service + // (netfilter-persistent); restarting it twice is redundant. if f.IP6Service == f.IP4Service { return nil } - return f.restartUnit(ctx, f.IP6Service) + return restartService(ctx, f.IP6Service) } -// Close closes the connection to the manager. +// Close releases manager resources. func (f *IPTables) Close(ctx context.Context) error { - f.Conn.Close() return nil } @@ -3065,11 +3019,15 @@ func (f *IPTables) Close(ctx context.Context) error { // 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. + // Fan each DirAny rule out into an input row plus its swapped output row, and + // each TCPUDP rule into a tcp row plus a udp row, before the per-family loop so + // each half marshals into its own chain line. Directions expand first, then + // protocols, so a DirAny+TCPUDP rule yields four concrete rows. var expanded []*Rule for _, r := range rules { - expanded = append(expanded, expandDirections(r)...) + for _, d := range expandDirections(r) { + expanded = append(expanded, expandProtocols(d)...) + } } rules = expanded @@ -3139,6 +3097,27 @@ func (f *IPTables) ReplaceRulesBatch(ctx context.Context, zoneName string, rules return f.applyRulesBatch(rules, true) } +// detectIPSetLayout reports the ipset save file and restore service to persist +// sets with, or empty strings when the packaging's persistence mechanism is not +// installed. The Debian layout restores sets through a netfilter-persistent +// plugin (proven by ipsetPlugin's presence); the RHEL layout uses a dedicated +// ipset service (proven by its unit or init.d script existing). +func (f *IPTables) detectIPSetLayout(ctx context.Context, layout iptLayout) (path, service string) { + if layout.ipsetPath == "" { + return "", "" + } + if layout.ipsetPlugin != "" { + if matches, _ := filepath.Glob(layout.ipsetPlugin); len(matches) == 0 { + return "", "" + } + return layout.ipsetPath, layout.ipsetService + } + if !serviceInstalled(ctx, layout.ipsetService) { + return "", "" + } + return layout.ipsetPath, layout.ipsetService +} + // prepareAddRuleFile writes an updated copy of filePath, with r inserted, to a // staged atomicFile and returns it uncommitted. It returns a nil handle (and nil // error) when no change is needed because the rule already exists. On error the @@ -3235,45 +3214,42 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er // 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. +// It shares its rule-location logic with prepareMoveRuleFile: both locate a rule +// equal to r (and its LOG partner, if any) via extractRuleLines and splice those +// lines out, so the LOG+action pairing and the *nat/*mangle scoping it depends on +// are defined in exactly one place. A removal clears every line the target covers, +// so a chain holding the same rule twice comes back clean in one pass; the target +// reaching here is already one concrete family/transport/direction cell, since +// applyRuleFiles fanned out the merged axes before calling. func (f *IPTables) prepareRemoveRuleFile(filePath string, r *Rule) (*atomicFile, error) { lines, err := f.readAllLines(filePath) if err != nil { return nil, err } - extracted, removedIdx, err := f.extractRuleLines(lines, r) - if err != nil { - return nil, err + found := false + for { + extracted, removedIdx, err := f.extractRuleLines(lines, r) + if err != nil { + return nil, err + } + if removedIdx < 0 { + break + } + found = true + without := make([]string, 0, len(lines)-len(extracted)) + for i, l := range lines { + if i >= removedIdx && i < removedIdx+len(extracted) { + continue + } + without = append(without, l) + } + lines = without } - if removedIdx < 0 { + if !found { // 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 + return f.stageLines(filePath, lines) } diff --git a/iptables_linux_test.go b/iptables_linux_test.go index 1295481..2749e9e 100644 --- a/iptables_linux_test.go +++ b/iptables_linux_test.go @@ -366,14 +366,6 @@ func TestIPTablesDefaultPolicyIgnoresNATTable(t *testing.T) { "the nat table's built-in chains must stay ACCEPT after a policy write") } -func TestIPSetTypeSpec(t *testing.T) { - f := new(IPTables) - require.Equal(t, "hash:ip family inet", f.ipsetTypeSpec(IPv4, SetHashIP)) - require.Equal(t, "hash:net family inet6", f.ipsetTypeSpec(IPv6, SetHashNet)) - // FamilyAny resolves to IPv4. - require.Equal(t, "hash:ip family inet", f.ipsetTypeSpec(FamilyAny, SetHashIP)) -} - // TestIPTablesLayoutDetection covers probeRHELLayout/probeDebianLayout's file // presence rules directly, without a live D-Bus/systemd (which NewIPTables // itself requires and which these helpers do not touch). @@ -392,11 +384,11 @@ func TestIPTablesLayoutDetection(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "sysconfig", "ip6tables"), nil, 0644)) l, ok := probeRHELLayout(root) 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, "iptables", l.ip4Service) + require.Equal(t, "ip6tables", 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") + require.Equal(t, "ipset", l.ipsetService) + require.Empty(t, l.ipsetPlugin, "the RHEL layout gates on the ipset service, not a plugin file") // RHEL layout: v4 present but v6 missing is an incomplete pair, not a match. root = t.TempDir() @@ -412,8 +404,8 @@ func TestIPTablesLayoutDetection(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "iptables", "rules.v6"), nil, 0644)) l, ok = probeDebianLayout(root) 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, "netfilter-persistent", l.ip4Service) + require.Equal(t, l.ip4Service, l.ip6Service, "the Debian layout restores both families from one service") 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") } @@ -633,10 +625,11 @@ func TestIPTablesICMPv6TypeNameParse(t *testing.T) { require.Equal(t, uint8(8), *r3.ICMPType, "icmp echo-request is type 8") } -// After GetRules merges an IPv4/IPv6 pair, the derived Number sequence must stay -// contiguous and unique within a direction rather than carrying the per-family -// numbering forward (which left gaps/duplicates once families were combined). -func TestIPTablesMergedRulesRenumberContiguously(t *testing.T) { +// iptables keeps each family in its own save file, so an IPv6 rule's position counts +// only the ip6tables chain it lives in. GetRules must number the two families +// independently — numbering the concatenation would offset every IPv6 rule by the +// length of the IPv4 chain and send InsertRule/MoveRule to the wrong line. +func TestIPTablesNumbersEachFamilyChainIndependently(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") @@ -646,36 +639,29 @@ func TestIPTablesMergedRulesRenumberContiguously(t *testing.T) { f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() - // A mergeable v4/v6 pair (port 22) plus a v4-only and a v6-only rule. All - // input direction. After merge there are three rules: one FamilyAny, one IPv4, - // one IPv6. - rules := []*Rule{ + // Two rules per family, all input direction. + for _, r := range []*Rule{ {Family: IPv4, Port: 22, Proto: TCP, Action: Accept}, {Family: IPv6, Port: 22, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, {Family: IPv6, Port: 443, Proto: TCP, Action: Accept}, - } - for _, r := range rules { + } { require.NoError(t, f.AddRule(ctx, "", r)) } got, err := f.GetRules(ctx, "") require.NoError(t, err) - require.Len(t, got, 3, "the v4/v6 port-22 pair should collapse to one rule") + require.Len(t, got, 4, "each save-file line is its own rule; nothing is collapsed") - // Every input rule's Number must be distinct and form the contiguous set - // {1,2,3} — no gaps, no cross-family duplicates. - nums := map[int]bool{} - var family22 Family = 255 + // Within each family's INPUT chain the numbers run 1..2, and no rule is reported + // as FamilyAny — a save-file line always belongs to exactly one family. + perFamily := map[Family][]int{} for _, r := range got { - require.False(t, nums[r.Number], "duplicate Number %d across merged families", r.Number) - nums[r.Number] = true - if r.Port == 22 { - family22 = r.Family - } + require.NotEqual(t, FamilyAny, r.Family, "an iptables line always names one family") + perFamily[r.Family] = append(perFamily[r.Family], r.Number) } - require.Equal(t, FamilyAny, family22, "the merged port-22 rule is family-agnostic") - require.Equal(t, map[int]bool{1: true, 2: true, 3: true}, nums, "numbers must be contiguous 1..3") + require.Equal(t, []int{1, 2}, perFamily[IPv4], "the IPv4 INPUT chain numbers 1..2") + require.Equal(t, []int{1, 2}, perFamily[IPv6], "the IPv6 INPUT chain numbers 1..2, not 3..4") } // GetDefaultPolicy reads both family save files: it returns the shared policy @@ -1616,33 +1602,6 @@ func TestIPTablesProtocolAndComment(t *testing.T) { } } -// The configured prefix comment is a tag, not a user label: it does not surface -// as a Rule.Comment on read, but a genuine user comment does. -func TestIPTablesPrefixCommentStripped(t *testing.T) { - dir := t.TempDir() - scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" - p4 := filepath.Join(dir, "iptables") - p6 := filepath.Join(dir, "ip6tables") - require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) - require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) - f := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"} - ctx := context.Background() - - // A rule with no user comment is tagged with the prefix only. - require.NoError(t, f.AddRule(ctx, "", &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept})) - // A rule with a user comment carries it. - require.NoError(t, f.AddRule(ctx, "", &Rule{Family: IPv4, Port: 443, Proto: TCP, Action: Accept, Comment: "https"})) - - rules, err := f.GetRules(ctx, "") - require.NoError(t, err) - byPort := map[uint16]*Rule{} - for _, r := range rules { - byPort[r.Port] = r - } - require.Equal(t, "", byPort[22].Comment, "prefix tag must not surface as a comment") - require.Equal(t, "https", byPort[443].Comment, "user comment should round-trip") -} - // tempIPTables builds an iptables backend backed by scratch save files. func tempIPTables(t *testing.T) *IPTables { t.Helper() @@ -1804,3 +1763,156 @@ func TestPrefixedComment(t *testing.T) { require.Equal(t, c.wantHasPrefix, hasPrefix, "hasPrefix for (%q,%q)", c.prefix, c.stored) } } + +// A TCPUDP rule has no both-transports match in iptables, so AddRule must fan it +// out into a `-p tcp` line and a `-p udp` line in each family file it applies to. +// GetRules reports each line as its own rule; together they cover the rule. +func TestIPTablesTCPUDPFanOutRoundTrip(t *testing.T) { + dir := t.TempDir() + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // A FamilyAny TCPUDP rule materializes as a tcp row plus a udp row in both files. + require.NoError(t, fw.AddRule(ctx, "", &Rule{Proto: TCPUDP, Port: 22, Action: Accept})) + + for _, p := range []string{p4, p6} { + data, err := os.ReadFile(p) + require.NoError(t, err) + require.Contains(t, string(data), "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", "tcp row missing in %s", p) + require.Contains(t, string(data), "-A INPUT -p udp -m udp --dport 22 -j ACCEPT", "udp row missing in %s", p) + } + + // GetRules reports the four physical rows as four rules — that is the firewall's + // actual state — and together they cover exactly the rule that was written. + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 4, "a FamilyAny TCPUDP rule occupies four save-file lines") + + want := &Rule{Proto: TCPUDP, Port: 22, Action: Accept} + require.True(t, want.CoveredBy(rules), "the four rows cover the rule that was added") + for _, r := range rules { + require.True(t, want.Covers(r), "no row may widen the rule: %+v", r) + } +} + +// Re-adding a TCPUDP rule is idempotent (each fanned-out row dedups), and one +// RemoveRule of the TCPUDP rule clears both the tcp and the udp rows. +func TestIPTablesTCPUDPIdempotentAddAndRemove(t *testing.T) { + dir := t.TempDir() + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + rule := &Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept} + require.NoError(t, fw.AddRule(ctx, "", rule)) + require.NoError(t, fw.AddRule(ctx, "", rule)) + + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 2, "re-adding a TCPUDP rule must not duplicate its two rows") + require.True(t, rule.CoveredBy(rules)) + + // One RemoveRule clears both fanned-out rows. + require.NoError(t, fw.RemoveRule(ctx, "", rule)) + data, err := os.ReadFile(p4) + require.NoError(t, err) + require.NotContains(t, string(data), "--dport 22", "both the tcp and udp rows must be removed") + rules, err = fw.GetRules(ctx, "") + require.NoError(t, err) + require.Empty(t, rules, "removing the TCPUDP rule must clear the chain") +} + +// Removing only the TCP half of a merged tcp/udp rule must leave the udp row in +// place, and the read-back must report the surviving rule as Proto==UDP. iptables +// only ever stores concrete-protocol rows, so this needs no dual-row split: the +// concrete TCP target simply deletes the tcp row via EqualForRemoval/EqualBase. +func TestIPTablesTCPUDPRemoveOneHalf(t *testing.T) { + dir := t.TempDir() + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept})) + + // Remove only the tcp half. + require.NoError(t, fw.RemoveRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept})) + + data, err := os.ReadFile(p4) + require.NoError(t, err) + require.NotContains(t, string(data), "-p tcp -m tcp --dport 22", "the tcp row must be removed") + require.Contains(t, string(data), "-A INPUT -p udp -m udp --dport 22 -j ACCEPT", "the udp row must survive") + + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 1, "only the udp row should remain") + require.Equal(t, UDP, rules[0].Proto, "the surviving row must read back as Proto==UDP") +} + +// InsertRule places a fanned-out tcp/udp pair as a block at the requested position. +// Each chain line is its own rule, so a TCPUDP rule already in the chain occupies two +// positions and the caller counts them both. +func TestIPTablesTCPUDPInsertPositionPastTransportPair(t *testing.T) { + dir := t.TempDir() + // INPUT holds a tcp row and a udp row on port 22 (positions 1 and 2) followed by + // a plain tcp rule on port 80 (position 3). + save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + + "-A INPUT -p udp -m udp --dport 22 -j ACCEPT\n" + + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + + "COMMIT\n" + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // Insert a TCPUDP rule at position 3, i.e. after both port-22 rows and before the + // port-80 row. + require.NoError(t, fw.InsertRule(ctx, "", 3, &Rule{Family: IPv4, Proto: TCPUDP, Port: 443, Action: Accept})) + + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 5, "the inserted pair adds two chain lines") + + // The port-443 rule now covers both transports, and the port-80 row is last. + want := &Rule{Family: IPv4, Proto: TCPUDP, Port: 443, Action: Accept} + require.True(t, want.CoveredBy(rules), "both transports of the inserted rule must be present") + require.Equal(t, 5, rules[4].Number, "the chain holds five rules") + require.EqualValues(t, 80, rules[4].Port, "the port-80 rule shifts to the end") + + // Assert the physical placement: both port-22 rows precede both new port-443 + // rows, which in turn precede the port-80 row — the insert must not land inside + // the port-22 pair. + data, err := os.ReadFile(p4) + require.NoError(t, err) + body := string(data) + last22 := strings.LastIndex(body, "--dport 22") + first443 := strings.Index(body, "--dport 443") + last443 := strings.LastIndex(body, "--dport 443") + first80 := strings.Index(body, "--dport 80") + require.Less(t, last22, first443, "both port-22 rows must precede the port-443 pair, not be split by it") + require.Less(t, last443, first80, "the port-443 pair must precede the port-80 row") +} + +// MarshalRule on a TCPUDP rule must still error: it is the row-level marshaller, +// and a TCPUDP rule must be fanned out into tcp and udp rows before reaching it. +func TestIPTablesMarshalTCPUDPErrors(t *testing.T) { + fw := new(IPTables) + _, err := fw.MarshalRule(&Rule{Proto: TCPUDP, Port: 80, Action: Accept}) + require.Error(t, err, "MarshalRule must reject an unexpanded TCPUDP rule") +} diff --git a/nft_linux.go b/nft_linux.go index 7fcaf9f..f15f8ef 100644 --- a/nft_linux.go +++ b/nft_linux.go @@ -11,8 +11,6 @@ import ( ) const ( - // NFTType is the manager type string reported by this backend. - NFTType = "nftables" // NFTDefaultTable is the table name used when no rule prefix is supplied. NFTDefaultTable = "go_firewall" ) @@ -288,6 +286,26 @@ func (f *NFT) protoFromToken(tok string) Protocol { return ProtocolAny } +// l4ProtoFromToken decodes the value of a `meta l4proto` clause, which is either a +// single protocol name (protoFromToken) or an anonymous set. The only set this +// backend writes, and the only one the Rule model can represent, is the +// both-transports `{ tcp, udp }` of a TCPUDP rule; any other set belongs to a +// foreign rule whose protocol coverage a single Proto field cannot hold, so it is +// rejected rather than silently narrowed to one member or widened to ProtocolAny. +func (f *NFT) l4ProtoFromToken(tok string) (Protocol, error) { + if !strings.HasPrefix(tok, "{") { + return f.protoFromToken(tok), nil + } + members := f.parseSetTokens(tok) + if len(members) == 2 { + a, b := f.protoFromToken(members[0]), f.protoFromToken(members[1]) + if (a == TCP && b == UDP) || (a == UDP && b == TCP) { + return TCPUDP, nil + } + } + return ProtocolAny, fmt.Errorf("unsupported l4proto set: %s", tok) +} + // 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 { @@ -429,8 +447,13 @@ func (f *NFT) UnmarshalRule(line string, chain string) (r *Rule, handle string, } r.ICMPType = Ptr(n) } - case "tcp", "udp", "sctp": - r.Proto = GetProtocol(tokens[i]) + case "tcp", "udp", "sctp", "th": + // `th` is the transport-header selector a TCPUDP rule matches its port + // through; the protocol itself came from the `meta l4proto { tcp, udp }` + // clause that precedes it, so do not overwrite it here. + if tokens[i] != "th" { + r.Proto = GetProtocol(tokens[i]) + } // A `dport` or `sport` qualifier may follow. if i+2 < len(tokens) && (tokens[i+1] == "dport" || tokens[i+1] == "sport") { src := tokens[i+1] == "sport" @@ -463,13 +486,17 @@ func (f *NFT) UnmarshalRule(line string, chain string) (r *Rule, handle string, } } case "meta": - // meta l4proto or meta nfproto + // meta l4proto or meta nfproto if i+2 >= len(tokens) { return nil, "", fmt.Errorf("unsupported meta match") } switch tokens[i+1] { case "l4proto": - r.Proto = f.protoFromToken(tokens[i+2]) + p, perr := f.l4ProtoFromToken(tokens[i+2]) + if perr != nil { + return nil, "", perr + } + r.Proto = p case "nfproto": switch tokens[i+2] { case "ipv4": @@ -667,9 +694,14 @@ func (f *NFT) listChain(ctx context.Context, chain string) (rules []*Rule, handl 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. +// listOwnRules returns the library's own filter rules from its private table, one +// rule per physical chain row. A read does not create the table; listChain returns +// nothing when the table does not yet exist. nftables' inet table stores a +// family-agnostic rule as one unpinned row and a both-transports rule as one +// `meta l4proto { tcp, udp }` row, so UnmarshalRule reports FamilyAny and TCPUDP +// straight off the row that carries them; nothing is collapsed here. Number per +// direction (input then output chain) so each rule's Number matches the +// InsertRule/MoveRule position within its chain. func (f *NFT) listOwnRules(ctx context.Context) ([]*Rule, error) { var rules []*Rule for _, chain := range nftFilterChains { @@ -679,21 +711,13 @@ func (f *NFT) listOwnRules(ctx context.Context) ([]*Rule, error) { } rules = append(rules, chainRules...) } - // Merge families first, number per direction (input then output chain) so a - // collapsed v4/v6 pair leaves no gap and each rule's Number matches the - // InsertRule/MoveRule position within its chain, then collapse each input/output - // twin into one DirAny rule (numbering first keeps the surviving pure-output - // rows' physical position). - merged := mergeFamilies(rules) - numberByDirection(merged) - merged = mergeDirections(merged) - return merged, nil + numberByDirection(rules) + return rules, nil } // GetRules returns the existing filter rules from the zone. func (f *NFT) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { - // The library's own merged rules, then foreign rules from every other table - // unmerged (their identity spans tables we do not own). + // The library's own rules, then foreign rules from every other table. rules, err = f.listOwnRules(ctx) if err != nil { return nil, err @@ -755,10 +779,19 @@ func (f *NFT) l3Match(family Family, addr string) string { return "ip" } +// nftTCPUDPSet is the anonymous nftables set that pins a rule to both transports. +// It is how this backend spells TCPUDP, letting a both-transports rule live as a +// single nftables row rather than a fanned-out tcp/udp pair. +const nftTCPUDPSet = "{ tcp, udp }" + // 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. +// TCPUDP is spelled as the anonymous both-transports set. func (f *NFT) l4Proto(p Protocol) string { + if p == TCPUDP { + return nftTCPUDPSet + } return p.String() } @@ -848,14 +881,23 @@ func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) { 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()))) - } + // Protocol / port match. A TCPUDP rule pins both transports with an anonymous + // set and matches the port through `th`, the transport-header selector, which is + // valid precisely because l4proto is constrained to port-carrying protocols. That + // keeps the rule a single nftables row, so it needs no fan-out and reads back as + // written; every other protocol names itself before its port. srcSpecs := r.SourcePortSpecs() + portKeyword := r.Proto.String() + if r.Proto == TCPUDP { + parts = append(parts, "meta l4proto "+nftTCPUDPSet) + portKeyword = "th" + } + if r.HasPorts() { + // A port-carrying protocol is guaranteed by the check above. + parts = append(parts, fmt.Sprintf("%s dport %s", portKeyword, f.portExpr(r.PortSpecs()))) + } if len(srcSpecs) > 0 { - parts = append(parts, fmt.Sprintf("%s sport %s", r.Proto.String(), f.portExpr(srcSpecs))) + parts = append(parts, fmt.Sprintf("%s sport %s", portKeyword, f.portExpr(srcSpecs))) } if r.Proto.IsICMP() && r.ICMPType != nil { // An ICMP type match implies the icmp/icmpv6 protocol. @@ -864,7 +906,7 @@ func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) { kw = "icmpv6" } parts = append(parts, fmt.Sprintf("%s type %d", kw, *r.ICMPType)) - } else if r.Proto != ProtocolAny && !r.HasPorts() && len(srcSpecs) == 0 { + } else if r.Proto != ProtocolAny && r.Proto != TCPUDP && !r.HasPorts() && len(srcSpecs) == 0 { parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto)) } @@ -1025,9 +1067,9 @@ func (f *NFT) insertRule(ctx context.Context, zoneName string, position int, r * } if position >= 1 { - // position is a merged Number; map it back to a physical chain index so a - // merged v4/v6 pair earlier in the chain does not skew the placement. - insPos := mergedInsertIndex(mergedFamilyAnchors(existing), len(existing), position) + // Each chain row is its own rule, so a Number is already a physical index; + // a position past the last row appends. + insPos := min(position-1, len(existing)) args := f.placeArgs(chain, expr, insPos, len(existing)) _, err = runCommand(ctx, "nft", args...) return err @@ -1096,45 +1138,32 @@ func (f *NFT) MoveRule(ctx context.Context, zoneName string, r *Rule, position i return err } - // Collect every row that belongs to this rule. GetRules merges an IPv4 and IPv6 - // row that differ only in family into one FamilyAny rule, so moving that merged - // rule must relocate BOTH underlying rows. Deleting only the first half and - // re-inserting a single dual-family rule would orphan the twin — the same hazard - // RemoveRule guards against via EqualForRemoval. A concrete-family target - // still moves only its own family. + // Collect every row this rule covers. A FamilyAny or TCPUDP target spans rows the + // chain may hold separately (an IPv4 row and an IPv6 row added one at a time), so + // moving it must relocate all of them; relocating only the first would orphan the + // rest — the same hazard RemoveRule guards against via EqualForRemoval. A + // concrete-family target still moves only its own family. firstIdx := -1 var toDelete []string - deleted := make(map[int]bool) for i, e := range rules { if e.EqualForRemoval(r, true) { if firstIdx < 0 { firstIdx = i } toDelete = append(toDelete, handles[i]) - deleted[i] = true } } if firstIdx < 0 { return nil } - // position and each rule's Number live in the merged (post-mergeFamilies) index - // space, while the chain holds physical rows; map through the chain's merged - // anchors so a merged v4/v6 pair does not skew the target. curPos is the target - // rule's current merged position (its anchor is firstIdx, the first matching row). - anchors := mergedFamilyAnchors(rules) - if position > len(anchors) { - position = len(anchors) - } - curPos := 0 - for p, idx := range anchors { - if idx == firstIdx { - curPos = p + 1 - break - } + // Each chain row is its own rule, so a position is a physical index. The target's + // current position is that of its first matching row. + if position > len(rules) { + position = len(rules) } // If moving to the same position, nothing to do. - if position == curPos { + if position == firstIdx+1 { return nil } @@ -1150,17 +1179,11 @@ func (f *NFT) MoveRule(ctx context.Context, zoneName string, r *Rule, position i if err != nil { return err } - // The rows were just deleted, so the chain now holds the remaining rows; map the - // target merged position to a physical index within that reduced chain (appending - // when the target is at or past the end). - reduced := make([]*Rule, 0, len(rules)-len(toDelete)) - for i, e := range rules { - if !deleted[i] { - reduced = append(reduced, e) - } - } - insPos := mergedInsertIndex(mergedFamilyAnchors(reduced), len(reduced), position) - args := f.placeArgs(chain, expr, insPos, len(reduced)) + // The rows were just deleted, so the chain now holds the remaining rows; clamp the + // target position to that reduced chain (appending when it is at or past the end). + remaining := len(rules) - len(toDelete) + insPos := min(position-1, remaining) + args := f.placeArgs(chain, expr, insPos, remaining) _, err = runCommand(ctx, "nft", args...) return err } @@ -1189,39 +1212,33 @@ func (f *NFT) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { if err != nil { return err } - // Delete every matching row, not just the first. GetRules merges an IPv4 and - // IPv6 row that differ only in family into one FamilyAny rule, so removing that - // merged rule must clear both underlying rows or its twin is orphaned (and a - // second reconcile pass would be needed to converge). A concrete-family target - // still removes only its own family — see EqualForRemoval. - // A genuine dual-family row is its own merged anchor (FamilyAny rows never merge - // into a pair), so its merged position is where the re-added family must land to - // preserve ordering. Capture the anchors before the delete shifts the chain. - anchors := mergedFamilyAnchors(rules) - var reAdd *Rule + // Delete every row the target covers, not just the first: a FamilyAny target + // clears both an unpinned row and any family-pinned rows it spans, and a TCPUDP + // target clears both transports. A concrete-family target still removes only its + // own family — see EqualForRemoval. + var reAdd []*Rule reAddPos := 0 for i, e := range rules { if e.EqualForRemoval(r, true) { if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil { return err } - // A concrete-family target that matched a genuine dual-family row (an inet - // rule with no family pin, covering both) would drop both families; re-add - // the untargeted family below, at the dual row's own merged position so the - // surviving family keeps the removed rule's place in the chain. - if s := splitDualRow(e, r); s != nil { + // A concrete target that matched a multi-state row would drop coverage the + // caller never asked to remove: an unpinned inet row covers both families, and + // a `meta l4proto { tcp, udp }` row both transports. Re-add the remainder at + // the row's own position so it keeps the removed rule's place in the chain. + if s := splitMergedRow(e, r); len(s) > 0 { reAdd = s - for p, idx := range anchors { - if idx == i { - reAddPos = p + 1 - break - } - } + reAddPos = i + 1 } } } - if reAdd != nil { - return f.insertRule(ctx, zoneName, reAddPos, reAdd) + // Re-add in order so the remainder rows keep the removed row's position; each + // insert shifts the next one down by a slot. + for n, s := range reAdd { + if err := f.insertRule(ctx, zoneName, reAddPos+n, s); err != nil { + return err + } } // Nothing left to remove. return nil @@ -1483,8 +1500,8 @@ func (f *NFT) listNATChain(ctx context.Context, chain string) (rules []*NATRule, return rules, handles, nil } -// listOwnNATRules returns the library's own NAT rules from its private table, -// with the IPv4/IPv6 pairs merged. +// listOwnNATRules returns the library's own NAT rules from its private table, one +// rule per physical chain row. func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) { var rules []*NATRule for _, chain := range []string{"prerouting", "postrouting"} { @@ -1494,12 +1511,12 @@ func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) { } rules = append(rules, chainRules...) } - // Merge families first, then number per nat chain (prerouting then postrouting) - // so a collapsed v4/v6 pair leaves no gap and each rule's Number matches the + // The nat chains live in the same inet table, so a family-agnostic translation is + // one unpinned row that reads back as FamilyAny; nothing is collapsed here. Number + // per nat chain (prerouting then postrouting) so each rule's Number matches the // InsertNATRule position within its chain. - merged := mergeNATFamilies(rules) - numberNATByChain(merged) - return merged, nil + numberNATByChain(rules) + return rules, nil } // GetNATRules returns the existing NAT rules from the zone. @@ -1695,9 +1712,9 @@ func (f *NFT) InsertNATRule(ctx context.Context, zoneName string, position int, if position <= 0 { position = 1 } - // position is a merged Number; map it back to a physical nat-chain index so a - // merged v4/v6 NAT pair earlier in the chain does not skew the placement. - insPos := mergedInsertIndex(mergedNATFamilyAnchors(existing), len(existing), position) + // Each nat-chain row is its own rule, so a Number is already a physical index; a + // position past the last row appends. + insPos := min(position-1, len(existing)) args := f.placeArgs(chain, expr, insPos, len(existing)) _, err = runCommand(ctx, "nft", args...) return err @@ -1718,9 +1735,9 @@ func (f *NFT) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) er if err != nil { return err } - // Delete every matching row (see RemoveRule): a merged FamilyAny NAT rule must - // clear both its v4 and v6 rows, while a concrete-family target removes only - // its own family. + // Delete every matching row (see RemoveRule): a FamilyAny NAT target must clear + // both the unpinned row it names and any family-pinned rows it covers, while a + // concrete-family target removes only its own family. for i, e := range rules { if e.EqualForRemoval(r) { if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil { @@ -1731,65 +1748,6 @@ func (f *NFT) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) er return nil } -// 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 // ActionInvalid when the table or chain does not yet exist (no policy to // report) or the policy is not recognized. @@ -2137,6 +2095,65 @@ func (f *NFT) RemoveAddressSetEntry(ctx context.Context, name, entry string) err 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) +} + // 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 diff --git a/nft_linux_test.go b/nft_linux_test.go index 1857989..00e2400 100644 --- a/nft_linux_test.go +++ b/nft_linux_test.go @@ -203,20 +203,6 @@ func TestNFTCounters(t *testing.T) { require.Zero(t, r2.Bytes) } -func TestNFTSetSpec(t *testing.T) { - f := new(NFT) - spec, err := f.setSpec(IPv4, SetHashIP) - require.NoError(t, err) - require.Equal(t, "{ type ipv4_addr ; }", spec) - spec, err = f.setSpec(IPv6, SetHashNet) - require.NoError(t, err) - require.Equal(t, "{ type ipv6_addr ; flags interval ; }", spec) - // FamilyAny resolves to IPv4. - spec, err = f.setSpec(FamilyAny, SetHashIP) - require.NoError(t, err) - require.Equal(t, "{ type ipv4_addr ; }", spec) -} - // collapseSetSpaces must not collapse the spaces inside brace-like content // that lives inside a quoted comment (it is not a real anonymous set), while // still collapsing an actual anonymous set match outside any quote. nft's @@ -410,16 +396,6 @@ func TestNFTSCTPNATRoundTrip(t *testing.T) { require.True(t, orig.EqualBase(got), "sctp nat rule must round-trip; got %+v", got) } -// nft interval sets store a non-CIDR span as a range object; GetAddressSet must -// report it as "lo-hi" rather than silently dropping the entry. -func TestNFTDecodeRangeElem(t *testing.T) { - f := new(NFT) - require.Equal(t, "10.0.0.1-10.0.0.9", f.decodeElem([]byte(`{"range":["10.0.0.1","10.0.0.9"]}`))) - // Real nft renders a CIDR element as a prefix object with addr/len fields. - require.Equal(t, "10.0.0.0/24", f.decodeElem([]byte(`{"prefix":{"addr":"10.0.0.0","len":24}}`))) - require.Equal(t, "192.0.2.1", f.decodeElem([]byte(`"192.0.2.1"`))) -} - // `nft -a list ruleset` appends `{ # handle N` (and sometimes ` progname ...`) // to table/chain header lines. headerName must return the bare object name so // listForeignRules skips our own table; a naive TrimSuffix(line, "{") left the @@ -532,20 +508,6 @@ func TestNFTDecodePrefixElemObject(t *testing.T) { } } -// A rule that names the netfilter default burst (5) must round-trip Equal to -// itself. nft reads the default burst back as 0, so without folding 5==0 the rule -// would churn on every Sync. Regression for eqRateLimit + the nft read path. -func TestNFTBurst5RoundTrip(t *testing.T) { - fw := &NFT{table: "go_firewall"} - in := &Rule{Proto: TCP, Port: 25, Action: Drop, - RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}} - chain, expr, err := fw.MarshalRule(in) - require.NoError(t, err) - got, _, err := fw.UnmarshalRule(expr, chain) - require.NoError(t, err) - require.True(t, in.Equal(got, true), "a Burst=5 rule must round-trip Equal to itself (expr %q)", expr) -} - // A LogPrefix ending in a single quote is an identity field that real nft stores // and lists back double-quoted (log prefix "block'"). The old trimQuotes stripped // leading/trailing ' as well as the surrounding ", corrupting it to "block" and @@ -659,3 +621,122 @@ func TestSanitizeNFTName(t *testing.T) { require.Equalf(t, c.want, sanitizeNFTName(c.in), "sanitizeNFTName(%q)", c.in) } } + +// TestNFTTCPUDPNativeRow covers the both-transports rule nftables expresses as a +// single row: `meta l4proto { tcp, udp }` pins the transports and `th dport` matches +// the port, so the rule needs no fan-out and reads back as written. +func TestNFTTCPUDPNativeRow(t *testing.T) { + f := &NFT{table: "gofw"} + + _, expr, err := f.MarshalRule(&Rule{Proto: TCPUDP, Port: 53, Action: Accept}) + require.NoError(t, err, "nftables expresses both transports in one row") + require.Contains(t, expr, "meta l4proto { tcp, udp }") + require.Contains(t, expr, "th dport 53") + require.NotContains(t, expr, "tcpudp", "tcpudp is the model's name, never nft syntax") + + // A source port uses the same transport-header selector. + _, expr, err = f.MarshalRule(&Rule{Proto: TCPUDP, SourcePort: 53, Action: Accept}) + require.NoError(t, err) + require.Contains(t, expr, "th sport 53") + + // Portless: the l4proto set alone pins both transports. + _, expr, err = f.MarshalRule(&Rule{Proto: TCPUDP, Action: Accept}) + require.NoError(t, err) + require.Contains(t, expr, "meta l4proto { tcp, udp }") + + // ProtocolAny with a port is still rejected: it means every IP protocol, which + // cannot carry a port. + _, _, err = f.MarshalRule(&Rule{Proto: ProtocolAny, Port: 53, Action: Accept}) + require.Error(t, err) +} + +// TestNFTTCPUDPRoundTrip: what MarshalRule emits, UnmarshalRule must read back as +// the same TCPUDP rule — nft lists the anonymous set verbatim. +func TestNFTTCPUDPRoundTrip(t *testing.T) { + f := &NFT{table: "gofw"} + src := &Rule{Proto: TCPUDP, Port: 53, Action: Accept, Direction: DirInput} + + _, expr, err := f.MarshalRule(src) + require.NoError(t, err) + + got, _, err := f.UnmarshalRule(expr, "input") + require.NoError(t, err, "the emitted row must parse back") + require.Equal(t, TCPUDP, got.Proto) + require.EqualValues(t, 53, got.Port) + require.True(t, got.EqualBase(src, true)) + + // A source-port row too. + src = &Rule{Proto: TCPUDP, SourcePort: 8080, Action: Drop, Direction: DirInput} + _, expr, err = f.MarshalRule(src) + require.NoError(t, err) + got, _, err = f.UnmarshalRule(expr, "input") + require.NoError(t, err) + require.Equal(t, TCPUDP, got.Proto) + require.EqualValues(t, 8080, got.SourcePort) + + // An l4proto set this backend never writes names coverage a single Proto field + // cannot hold, so it is rejected rather than silently narrowed or widened. + _, _, err = f.UnmarshalRule("meta l4proto { tcp, sctp } th dport 53 accept", "input") + require.Error(t, err, "an unsupported l4proto set must not parse") +} + +// A tcp row and a udp row added separately stay two rows on read — nftables holds a +// TCPUDP rule as one `meta l4proto { tcp, udp }` row, and a chain that instead holds +// the two concrete rows is a different actual state. The pair still covers the TCPUDP +// rule, so Sync does not re-add it. +func TestNFTSeparateTransportRowsCoverTCPUDP(t *testing.T) { + rows := []*Rule{ + {Family: FamilyAny, Proto: TCP, Port: 53, Action: Accept, Direction: DirInput}, + {Family: FamilyAny, Proto: UDP, Port: 53, Action: Accept, Direction: DirInput}, + } + both := &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept, Direction: DirInput} + require.True(t, both.CoveredBy(rows)) + require.False(t, both.CoveredBy(rows[:1]), "the tcp row alone leaves udp uncovered") + + // A removal targeting the TCPUDP rule reaches both rows. + require.True(t, rows[0].EqualForRemoval(both, true)) + require.True(t, rows[1].EqualForRemoval(both, true)) +} + +// TestNFTSplitMergedRowTwoAxes: nftables is the only backend that stores a row merged +// on both axes — an unpinned inet row covers both families, and an l4proto set both +// transports. Removing one cell of that grid must re-add the rest, never dropping +// coverage the caller did not target. +func TestNFTSplitMergedRowTwoAxes(t *testing.T) { + row := &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept} + + // Removing v4/tcp leaves v6 across both transports, plus v4/udp. + rest := splitMergedRow(row, &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}) + require.Len(t, rest, 2) + require.Equal(t, IPv6, rest[0].Family) + require.Equal(t, TCPUDP, rest[0].Proto) + require.Equal(t, IPv4, rest[1].Family, "the transport remainder is pinned to the targeted family") + require.Equal(t, UDP, rest[1].Proto) + + // Removing tcp across both families leaves udp across both families. + rest = splitMergedRow(row, &Rule{Family: FamilyAny, Proto: TCP, Port: 53, Action: Accept}) + require.Len(t, rest, 1) + require.Equal(t, FamilyAny, rest[0].Family) + require.Equal(t, UDP, rest[0].Proto) + + // Removing v4 across both transports leaves v6 across both transports. + rest = splitMergedRow(row, &Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}) + require.Len(t, rest, 1) + require.Equal(t, IPv6, rest[0].Family) + require.Equal(t, TCPUDP, rest[0].Proto) + + // Removing the whole row leaves nothing. + require.Empty(t, splitMergedRow(row, &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept})) + + // A single-axis row splits on that axis only. + require.Len(t, splitMergedRow(&Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}, + &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}), 1) +} + +// TestNFTNATRejectsMergedProtocol: NAT has no both-transports form on any backend, so +// validate rejects a TCPUDP NAT rule rather than emit a `tcpudp` protocol token. +func TestNFTNATRejectsMergedProtocol(t *testing.T) { + f := &NFT{table: "gofw"} + _, _, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCPUDP, Port: 80, ToAddress: "192.0.2.1"}) + require.ErrorIs(t, err, ErrUnsupportedNAT) +} diff --git a/pf.go b/pf.go index 7099a6e..51ed59c 100644 --- a/pf.go +++ b/pf.go @@ -606,8 +606,8 @@ func (f *PF) anchorRules(ctx context.Context) (rules []*Rule, raw []string, err // 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 -// rule set (a []*Rule cannot carry an unparseable line). +// read/number and backup paths use it, since those operate on the modeled rule set +// (a []*Rule cannot carry an unparseable line). func (f *PF) compactRules(rules []*Rule) []*Rule { out := make([]*Rule, 0, len(rules)) for _, r := range rules { @@ -670,15 +670,14 @@ func (f *PF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, 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) + // Report one rule per anchor row. A pf rule written without `inet`/`inet6` matches + // both families, and UnmarshalRule reports it as FamilyAny from that single row; + // the transport and direction axes have no both-states form in pf's grammar (this + // backend fans TCPUDP and DirAny out on write), so those rows read back concrete. + // Number the anchor's rules as one ordered list — pf evaluates a single filter + // list, so its position spans directions. Foreign rules appended below live + // outside this anchor and keep Number 0. 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 } @@ -776,6 +775,13 @@ func (f *PF) MarshalRule(r *Rule) (string, error) { if r.IsForward() { return "", unsupportedForward("pf") } + // pf has no both-transports rule form; pfctl expands a `{ tcp udp }` list into + // separate rows on load, so a TCPUDP rule must be fanned into a tcp row and a udp + // row by expandProtocols before it reaches this row-level marshaller. Reaching + // here with TCPUDP means that fan-out was skipped. + if err := r.CheckExpandedProtocol(); err != nil { + return "", err + } // 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") @@ -1148,8 +1154,6 @@ func (f *PF) writeFileLines(path string, lines []string) error { } 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. @@ -1251,6 +1255,17 @@ func (f *PF) AddRule(ctx context.Context, zoneName string, r *Rule) error { return nil } + // A TCPUDP rule fans out into a tcp rule and a udp rule; pfctl expands a + // `{ tcp udp }` list into separate rows on load, so it cannot be stored as one. + if r.Proto == TCPUDP { + for _, sub := range expandProtocols(r) { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + line, err := f.MarshalRule(r) if err != nil { return err @@ -1280,23 +1295,18 @@ func (f *PF) AddRule(ctx context.Context, zoneName string, r *Rule) error { return f.loadAnchor(ctx, natRaw, filterRaw) } -// 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. +// filterAnchors maps each logical 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. Every modeled row is its own +// rule — GetRules reports the anchor row for row — so with no opaque rows this is +// the identity. It backs the logical-position insert/move mapping. func (f *PF) filterAnchors(rules []*Rule) []int { - phys := make([]int, 0, len(rules)) - modeled := make([]*Rule, 0, len(rules)) + anchors := make([]int, 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]] + anchors = append(anchors, i) } return anchors } @@ -1319,6 +1329,17 @@ func (f *PF) InsertRule(ctx context.Context, zoneName string, position int, r *R return nil } + // A TCPUDP rule occupies a row for each transport; insert its tcp row and its udp + // row at the requested position, since pfctl stores the two transports separately. + if r.Proto == TCPUDP { + for _, sub := range expandProtocols(r) { + if err := f.InsertRule(ctx, zoneName, position, sub); err != nil { + return err + } + } + return nil + } + line, err := f.MarshalRule(r) if err != nil { return err @@ -1337,13 +1358,10 @@ func (f *PF) InsertRule(ctx context.Context, zoneName string, position int, r *R if position <= 0 { position = 1 } - // position is a merged Number: GetRules collapses IPv4/IPv6 pairs and numbers - // the result, while the anchor holds one physical row per rule. Map through the - // merged anchors so a collapsed pair earlier in the list does not skew the - // placement (and does not split a family pair). rules is 1:1 with filterRaw, and - // filterAnchors skips any opaque (unmodeled) row so it does not consume a - // logical position. - idx := mergedInsertIndex(f.filterAnchors(rules), len(filterRaw), position) + // position is a Number GetRules reported, which counts only the rules it can + // model. rules is 1:1 with filterRaw, and filterAnchors skips any opaque + // (unmodeled) row so a foreign anchor line does not consume a logical position. + idx := logicalInsertIndex(f.filterAnchors(rules), len(filterRaw), position) filterRaw = append(filterRaw[:idx], append([]string{line}, filterRaw[idx:]...)...) _, natRaw, err := f.anchorNATRules(ctx) @@ -1354,20 +1372,19 @@ func (f *PF) InsertRule(ctx context.Context, zoneName string, position int, r *R } // reorderRows returns the anchor's filter rows with every physical row matching r -// relocated to the merged 1-based position, and whether any row moved. A rule read -// back by GetRules can be a collapsed IPv4/IPv6 pair spanning two physical rows, so -// both twin rows are relocated together. rules is 1:1 with filterRaw. The target -// position lives in the merged (post-mergeFamilies) index space, so it is mapped to -// a physical index within the reduced row set. +// relocated to the 1-based position, and whether any row moved. A FamilyAny or +// TCPUDP target spans rows the anchor may hold separately, so every matching row is +// relocated together. rules is 1:1 with filterRaw. The target position counts only +// modeled rules, so it is mapped to a physical index within the reduced row set. func (f *PF) reorderRows(rules []*Rule, filterRaw []string, r *Rule, position int) ([]string, bool) { if position <= 0 { position = 1 } // Split the rows into the ones being moved and the ones staying, keeping the // kept rules 1:1 with the kept rows for the anchor mapping. Match with - // EqualForRemoval (as RemoveRule does), not the family-strict Equal, which could - // never match a merged rule the caller read back; a concrete-family target still - // moves only its own family row. + // EqualForRemoval (as RemoveRule does), not the family-strict Equal, so a + // FamilyAny target relocates the family-agnostic row it names; a concrete-family + // target still moves only its own family row. moved := make([]string, 0, 2) kept := make([]string, 0, len(filterRaw)) keptRules := make([]*Rule, 0, len(rules)) @@ -1383,7 +1400,7 @@ func (f *PF) reorderRows(rules []*Rule, filterRaw []string, r *Rule, position in if len(moved) == 0 { return nil, false } - newIdx := mergedInsertIndex(f.filterAnchors(keptRules), len(kept), position) + newIdx := logicalInsertIndex(f.filterAnchors(keptRules), len(kept), position) out := make([]string, 0, len(filterRaw)) out = append(out, kept[:newIdx]...) out = append(out, moved...) @@ -1529,9 +1546,10 @@ func (f *PF) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule } // 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) + // Report one rule per anchor row: a nat/rdr line written without `inet`/`inet6` + // matches both families and reads back as FamilyAny on its own. Number the + // anchor's NAT rules as one ordered list; foreign NAT rules appended below keep + // Number 0. numberNATSequential(rules) rules = append(rules, f.listForeignNATRules(ctx)...) return rules, nil @@ -1542,6 +1560,9 @@ func (f *PF) MarshalNATRule(r *NATRule) (string, error) { if err := r.validate(); err != nil { return "", err } + // A TCPUDP nat rule is rejected by validate above: no backend expresses NAT on + // both transports as one rule, so such a rule could never round-trip. + // // pfctl expands a discrete match-port list into one rule per port on read, so a // multi-port match would not round-trip as a single NAT rule (mirroring the // filter-rule guard). A contiguous range is one token and is allowed. @@ -1728,21 +1749,15 @@ 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. +// natAnchors is filterAnchors for NAT rules: it maps each logical 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)) + anchors := make([]int, 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]] + anchors = append(anchors, i) } return anchors } @@ -1775,13 +1790,10 @@ func (f *PF) InsertNATRule(ctx context.Context, zoneName string, position int, r if position <= 0 { position = 1 } - // position is a merged Number: GetNATRules collapses IPv4/IPv6 pairs and numbers - // the result, while the anchor holds one physical row per rule. Map through the - // merged NAT anchors so a collapsed pair earlier in the list does not skew the - // placement (and does not split a family pair). rules is 1:1 with natRaw, and - // natAnchors skips any opaque (unmodeled) row so it does not consume a - // logical position. - idx := mergedInsertIndex(f.natAnchors(rules), len(natRaw), position) + // position is a Number GetNATRules reported, which counts only the rules it can + // model. rules is 1:1 with natRaw, and natAnchors skips any opaque (unmodeled) + // row so a foreign anchor line does not consume a logical position. + idx := logicalInsertIndex(f.natAnchors(rules), len(natRaw), position) natRaw = append(natRaw[:idx], append([]string{line}, natRaw[idx:]...)...) // Preserve the filter rules that share the anchor. @@ -1803,11 +1815,11 @@ func (f *PF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) err return err } - // Rebuild the NAT ruleset without the matching row(s). GetNATRules collapses an - // IPv4/IPv6 twin into one FamilyAny rule (mergeNATFamilies), so removing that - // read-back rule must clear both underlying anchor rows — mirror RemoveRule: - // match with EqualForRemoval so a concrete-family target still removes only its - // own family and never the twin's row. + // Rebuild the NAT ruleset without the matching row(s). A FamilyAny target spans + // rows the anchor may hold separately (an IPv4 row and an IPv6 row added one at a + // time), so removing it must clear all of them — mirror RemoveRule: match with + // EqualForRemoval so a concrete-family target still removes only its own family + // and never the twin's row. kept := make([]string, 0, len(natRaw)) removed := false for i, e := range rules { @@ -1829,76 +1841,6 @@ func (f *PF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) err return f.loadAnchor(ctx, kept, filterRaw) } -// 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. func (f *PF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { return nil, unsupportedPolicy(f.Type()) @@ -2040,6 +1982,76 @@ func (f *PF) RemoveAddressSetEntry(ctx context.Context, name, entry string) erro return err } +// 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) +} + // Reload is a no-op; pf applies anchor changes immediately. func (f *PF) Reload(ctx context.Context) error { return nil @@ -2068,24 +2080,28 @@ func (f *PF) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) } 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 + // A DirAny rule fans out into an inbound rule plus its swapped outbound rule, + // and a TCPUDP rule into a tcp rule and a udp rule; pfctl stores each + // direction/transport as its own row. + for _, dir := range expandDirections(top) { + for _, r := range expandProtocols(dir) { + 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) } - if dup { - continue - } - filterRaw = append(filterRaw, line) - existing = append(existing, r) } } return f.loadAnchor(ctx, natRaw, filterRaw) @@ -2106,13 +2122,17 @@ func (f *PF) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Ru 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 + // A DirAny rule fans out into an inbound rule plus its swapped outbound rule, + // and a TCPUDP rule into a tcp rule and a udp rule; pfctl stores each + // direction/transport as its own row. + for _, dir := range expandDirections(top) { + for _, r := range expandProtocols(dir) { + line, err := f.MarshalRule(r) + if err != nil { + return err + } + filterRaw = append(filterRaw, line) } - filterRaw = append(filterRaw, line) } } return f.loadAnchor(ctx, natRaw, filterRaw) diff --git a/pf_test.go b/pf_test.go index bec570a..ff988a3 100644 --- a/pf_test.go +++ b/pf_test.go @@ -142,9 +142,9 @@ func TestPFAnchorPreservesUnmodeled(t *testing.T) { } // TestPFReorderRowsKeepsOpaque verifies the move/remove row rebuild keeps an opaque -// (nil) row in place and maps the merged target position to the correct physical -// index past it, so relocating a modeled rule never drops or displaces a foreign -// line sharing our anchor. +// (nil) row in place and maps the target position to the correct physical index past +// it, so relocating a modeled rule never drops or displaces a foreign line sharing +// our anchor. func TestPFReorderRowsKeepsOpaque(t *testing.T) { fw := new(PF) ruleA := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept} @@ -333,7 +333,6 @@ func TestPFFeatureRules(t *testing.T) { // ErrUnsupportedForward sentinel. _, err = fw.MarshalRule(&Rule{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept}) require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected") - require.False(t, fw.Capabilities().Forward, "pf does not advertise forward support") } func TestPFLogLimitRoundTrip(t *testing.T) { @@ -427,60 +426,61 @@ func TestPFLabelConsecutiveSpaces(t *testing.T) { } } -// A pf IPv4/IPv6 filter twin is collapsed by mergeFamilies into one FamilyAny -// rule (impliedFamily FamilyAny). RemoveRule/MoveRule must locate that read-back -// rule with EqualForRemoval, not the family-strict Equal — which never matches -// it, so the port stays open. Regression for the pf remove no-op on a merged rule. -func TestPFMergedFilterTwinIsRemovable(t *testing.T) { +// A pf rule written per family lives in two anchor rows. RemoveRule/MoveRule must +// locate both from a FamilyAny target with EqualForRemoval, not the family-strict +// Equal — which matches neither, so the port stays open. Regression for the pf remove +// no-op on a family-agnostic target. +func TestPFFamilyAnyTargetMatchesBothTwins(t *testing.T) { f := &PF{anchor: "go_firewall"} v4, err := f.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22") require.NoError(t, err) v6, err := f.UnmarshalRule("pass in quick inet6 proto tcp from any to any port = 22") require.NoError(t, err) - merged := mergeFamiliesCopy([]*Rule{v4, v6}) - require.Len(t, merged, 1, "the v4/v6 twin must collapse into one rule") - m := merged[0] - require.Equal(t, FamilyAny, m.impliedFamily()) + // The two rows cover the family-agnostic rule between them. + target := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept, Direction: DirInput} + require.True(t, target.CoveredBy([]*Rule{v4, v6})) - // The old family-strict matcher could find neither physical row. - require.False(t, m.Equal(v4, true)) - require.False(t, m.Equal(v6, true)) + // The family-strict matcher finds neither physical row. + require.False(t, target.Equal(v4, true)) + require.False(t, target.Equal(v6, true)) - // The new matcher (EqualForRemoval) finds both, so RemoveRule clears both anchor - // rows and MoveRule can locate the rule. - require.True(t, v4.EqualForRemoval(m, true)) - require.True(t, v6.EqualForRemoval(m, true)) + // EqualForRemoval finds both, so RemoveRule clears both anchor rows and MoveRule + // can locate the rule. + require.True(t, v4.EqualForRemoval(target, true)) + require.True(t, v6.EqualForRemoval(target, true)) } -// MoveRule must relocate BOTH physical rows of a merged IPv4/IPv6 pair, not just -// the first. mergeFamilies re-pairs rows on the lower physical index, so moving only -// the first row of the pair leaves the twin at the earlier index — which then wins -// on the next read, making a move to a LATER position a silent no-op. reorderRows -// moves the pair as a block. Regression for the pf MoveRule merged-pair defect. -func TestPFReorderRowsMergedPair(t *testing.T) { +// MoveRule must relocate every physical row a target covers, not just the first. +// Moving only the first row of a v4/v6 pair leaves the twin at the earlier index — +// which then wins on the next read, making a move to a LATER position a silent no-op. +// reorderRows moves the covered rows as a block. Regression for the pf MoveRule +// family-pair defect. +func TestPFReorderRowsFamilyPair(t *testing.T) { fw := new(PF) mk := func(fam Family, port uint16) *Rule { return &Rule{Family: fam, Proto: TCP, Port: port, Action: Accept} } // Physical anchor rows: A is a v4/v6 pair (rows 0,1); B is a v4/v6 pair (rows 2,3). - // GetRules merges these to A(Number 1), B(Number 2). + // GetRules reports four rules, numbered 1..4. rules := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(IPv4, 80), mk(IPv6, 80)} raw := []string{"A_v4", "A_v6", "B_v4", "B_v6"} - // Move merged A (read back as a collapsed FamilyAny pair) to position 2 — after B. - out, moved := fw.reorderRows(rules, raw, mk(FamilyAny, 22), 2) + // Move both A rows past B. Once A is pulled out, two rows remain, so position 3 is + // past the end and appends. + out, moved := fw.reorderRows(rules, raw, mk(FamilyAny, 22), 3) require.True(t, moved) require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out, - "both rows of the pair must move together, landing after B") + "both rows the target covers must move together, landing after B") - // Move merged B up to the front (position 1) — the previously-working direction. + // Move both B rows up to the front. out, moved = fw.reorderRows(rules, raw, mk(FamilyAny, 80), 1) require.True(t, moved) require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out) // A concrete-family target relocates only its own family row, never the twin. - out, moved = fw.reorderRows(rules, raw, mk(IPv4, 22), 3) + // Three rows remain after A_v4 is pulled out, so position 4 appends. + out, moved = fw.reorderRows(rules, raw, mk(IPv4, 22), 4) require.True(t, moved) require.Equal(t, []string{"A_v6", "B_v4", "B_v6", "A_v4"}, out) @@ -489,6 +489,112 @@ func TestPFReorderRowsMergedPair(t *testing.T) { require.False(t, moved) } +// pf has no both-transports rule form: pfctl expands a `{ tcp udp }` list into +// separate rows on load, so the write path fans a TCPUDP rule into a tcp row and a +// udp row before marshalling. A TCPUDP rule reaching the row-level marshaller means +// that fan-out was skipped, so MarshalRule and MarshalNATRule must reject it rather +// than emit a `proto tcpudp` line pfctl cannot load. ProtocolAny+ports stays rejected +// as a separate, distinct case (see TestPFRules). +func TestPFMarshalRejectsTCPUDP(t *testing.T) { + f := &PF{anchor: "go_firewall"} + _, err := f.MarshalRule(&Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept}) + require.Error(t, err, "a TCPUDP rule must not reach the row-level marshaller") + + // No backend expresses NAT on both transports as one rule, so a TCPUDP nat rule + // can never round-trip and must be rejected too. + _, err = f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCPUDP, Port: 80, ToAddress: "10.0.0.5"}) + require.ErrorIs(t, err, ErrUnsupported, "a TCPUDP nat rule must be rejected") +} + +// expandProtocols fans a TCPUDP rule into a tcp rule and a udp rule, each of which +// marshals to a valid concrete-protocol pf line and round-trips. pf has no both- +// transports form, so the write path fans out before the row-level marshaller. +func TestPFExpandProtocolsMarshal(t *testing.T) { + f := &PF{anchor: "go_firewall"} + subs := expandProtocols(&Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept}) + require.Len(t, subs, 2, "TCPUDP must fan into two concrete-transport rules") + require.Equal(t, TCP, subs[0].Proto) + require.Equal(t, UDP, subs[1].Proto) + for _, sub := range subs { + line, err := f.MarshalRule(sub) + require.NoError(t, err, "each fanned transport must marshal") + parsed, err := f.UnmarshalRule(line) + require.NoError(t, err, "line %q", line) + require.True(t, parsed.Equal(sub, true), "round-trip mismatch for %q", line) + } +} + +// pfctl stores the two transports as separate rows, and pf's grammar has no both- +// transports form, so a TCPUDP rule reads back as a tcp row and a udp row. The pair +// covers the rule that was written; a lone tcp row never does. +func TestPFSeparateTransportRowsCoverTCPUDP(t *testing.T) { + f := &PF{anchor: "go_firewall"} + tcp, err := f.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22") + require.NoError(t, err) + udp, err := f.UnmarshalRule("pass in quick inet proto udp from any to any port = 22") + require.NoError(t, err) + require.Equal(t, TCP, tcp.Proto, "each row keeps its own concrete transport") + require.Equal(t, UDP, udp.Proto) + + both := &Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept, Direction: DirInput} + require.True(t, both.CoveredBy([]*Rule{tcp, udp})) + require.False(t, both.CoveredBy([]*Rule{tcp}), "an unpaired tcp row must not cover a TCPUDP rule") + + // The TCPUDP target reaches both rows on removal. + require.True(t, tcp.EqualForRemoval(both, true)) + require.True(t, udp.EqualForRemoval(both, true)) +} + +// Every modeled anchor row is its own rule, so filterAnchors is the identity over +// them. An opaque (nil) row — an anchor line pf keeps but this backend cannot model — +// occupies a physical slot without consuming a logical position, so the rules after +// it must still map to their own physical rows. +func TestPFFilterAnchorsSkipOpaqueRows(t *testing.T) { + fw := new(PF) + mk := func(proto Protocol, port uint16) *Rule { + return &Rule{Family: IPv4, Proto: proto, Port: port, Action: Accept} + } + rules := []*Rule{mk(TCP, 22), mk(UDP, 22), mk(TCP, 80), mk(UDP, 80)} + require.Equal(t, []int{0, 1, 2, 3}, fw.filterAnchors(rules), + "every modeled row is its own anchor") + require.Equal(t, 1, logicalInsertIndex(fw.filterAnchors(rules), len(rules), 2)) + require.Equal(t, 4, logicalInsertIndex(fw.filterAnchors(rules), len(rules), 5), + "a position past the last logical rule appends") + + // An unmodeled line sits at physical row 1, shifting the rows after it. + withOpaque := []*Rule{mk(TCP, 22), nil, mk(UDP, 22), mk(TCP, 80)} + anchors := fw.filterAnchors(withOpaque) + require.Equal(t, []int{0, 2, 3}, anchors, "the opaque row consumes no logical position") + require.Equal(t, 2, logicalInsertIndex(anchors, len(withOpaque), 2), + "logical rule 2 lives at physical row 2, past the opaque line") +} + +// reorderRows must relocate every physical row a target covers together: pfctl stores +// tcp and udp as separate rows, so a caller moving a TCPUDP rule (matched via +// EqualForRemoval's protocol coverage) moves both. A concrete-transport target moves +// only its own transport row, never the twin's. +func TestPFReorderRowsTransportPair(t *testing.T) { + fw := new(PF) + mk := func(proto Protocol, port uint16) *Rule { + return &Rule{Family: IPv4, Proto: proto, Port: port, Action: Accept} + } + rules := []*Rule{mk(TCP, 22), mk(UDP, 22), mk(TCP, 80), mk(UDP, 80)} + raw := []string{"A_tcp", "A_udp", "B_tcp", "B_udp"} + + // Move both A rows past B. Two rows remain once A is pulled out, so position 3 + // appends. + out, moved := fw.reorderRows(rules, raw, mk(TCPUDP, 22), 3) + require.True(t, moved) + require.Equal(t, []string{"B_tcp", "B_udp", "A_tcp", "A_udp"}, out, + "both transport rows the target covers must move together, landing after B") + + // A concrete-transport target relocates only its own transport row. Three rows + // remain, so position 4 appends. + out, moved = fw.reorderRows(rules, raw, mk(TCP, 22), 4) + require.True(t, moved) + require.Equal(t, []string{"A_udp", "B_tcp", "B_udp", "A_tcp"}, out) +} + // writeFileLines must preserve the original file's mode (not loosen it to 0644) // and must not leave a fixed-name temp file behind. func TestWriteFileLinesPreservesMode(t *testing.T) { diff --git a/services.go b/services.go new file mode 100644 index 0000000..6a6cebb --- /dev/null +++ b/services.go @@ -0,0 +1,266 @@ +//go:build linux + +package firewall + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + dbus "github.com/coreos/go-systemd/dbus" +) + +// systemdActive reports whether systemd is the running init (/run/systemd/system +// exists), the same marker systemctl uses to decide it can reach the manager. +func systemdActive() bool { + _, err := os.Stat("/run/systemd/system") + return err == nil +} + +// commandExists reports whether name resolves on PATH. +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// systemdUnitEnabled reports whether systemd reports name's unit as set to +// start at boot. "enabled", "enabled-runtime", and "generated" all count; +// "generated" is how systemd wraps a SysV init.d script that was registered to +// start. Returns false when systemd is not running or the unit is absent. +func systemdUnitEnabled(ctx context.Context, name string) bool { + conn, err := dbus.NewWithContext(ctx) + if err != nil { + return false + } + defer conn.Close() + prop, err := conn.GetUnitPropertyContext(ctx, name+".service", "UnitFileState") + if err != nil { + return false + } + switch prop.Value.Value() { + case "enabled", "enabled-runtime", "generated": + return true + } + return false +} + +// systemdUnitState returns name's unit-file state (e.g. "enabled", "disabled", +// "static") and whether its unit file is installed. It lists all unit files and +// filters by name rather than calling ListUnitFilesByPatterns, which older +// systemd (CentOS 7's v219) does not export over D-Bus. Used to tell +// installed-but-disabled units (which can be enabled) from absent ones (which +// cannot be). Returns "", false when systemd is not running. +func systemdUnitState(ctx context.Context, name string) (state string, present bool) { + conn, err := dbus.NewWithContext(ctx) + if err != nil { + return "", false + } + defer conn.Close() + files, err := conn.ListUnitFilesContext(ctx) + if err != nil { + return "", false + } + target := name + ".service" + for _, uf := range files { + if filepath.Base(uf.Path) == target { + return uf.Type, true + } + } + return "", false +} + +// chkconfigOn reports whether `chkconfig --list name` shows any runlevel on, +// the RHEL-family enablement signal. Returns false when chkconfig is absent. +func chkconfigOn(ctx context.Context, name string) bool { + results, err := runCommand(ctx, "chkconfig", "--list", name) + if err != nil { + return false + } + for _, line := range results { + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] != name { + continue + } + for _, f := range fields[1:] { + if _, status, found := strings.Cut(f, ":"); found && status == "on" { + return true + } + } + } + return false +} + +// rcSymlinksOn reports whether an S*name start symlink exists in any rcN.d +// tree, the enablement signal for both update-rc.d (Debian/Ubuntu, /etc/rcN.d) +// and Slackware (/etc/rc.d/rcN.d). +func rcSymlinksOn(name string) bool { + for _, rl := range []string{"2", "3", "4", "5"} { + for _, dir := range []string{"/etc/rc" + rl + ".d", "/etc/rc.d/rc" + rl + ".d"} { + if matches, _ := filepath.Glob(filepath.Join(dir, "S*"+name)); len(matches) > 0 { + return true + } + } + } + return false +} + +// openrcOn reports whether name is linked into an OpenRC runlevel directory +// (default or boot), the enablement signal created by `rc-update add` on Gentoo. +func openrcOn(name string) bool { + for _, rl := range []string{"default", "boot"} { + if _, err := os.Lstat(filepath.Join("/etc/runlevels", rl, name)); err == nil { + return true + } + } + return false +} + +// rcLocalOn reports whether name is invoked from an rc.local file, apf's +// last-resort enablement when neither systemd nor an init.d registration +// applies. Commented lines are ignored. +func rcLocalOn(name string) bool { + for _, p := range []string{"/etc/rc.local", "/etc/rc.d/rc.local"} { + data, err := os.ReadFile(p) + if err != nil { + continue + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.Contains(line, name) { + return true + } + } + } + return false +} + +// sysvServiceEnabled reports whether name is enabled under any SysV-family init +// system, the fallback when systemd is absent or does not report the service +// enabled. It checks chkconfig, Debian/Slackware rcN.d start symlinks, OpenRC +// runlevels, and rc.local in turn; the first to report it on wins. +func sysvServiceEnabled(ctx context.Context, name string) bool { + if chkconfigOn(ctx, name) { + return true + } + if rcSymlinksOn(name) { + return true + } + if openrcOn(name) { + return true + } + return rcLocalOn(name) +} + +// serviceInstalled reports whether name's unit or init.d script is installed, +// regardless of whether it is enabled. Used to tell a merely-disabled service +// (which can be enabled) from an absent one (which cannot be). +func serviceInstalled(ctx context.Context, name string) bool { + if systemdActive() { + _, present := systemdUnitState(ctx, name) + return present + } + for _, dir := range []string{"/etc/init.d", "/etc/rc.d/init.d"} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return true + } + } + return false +} + +// serviceEnabled reports whether name is enabled to start, checking systemd +// first and then every SysV-family init mechanism the supported firewalls +// install under: chkconfig (RHEL), update-rc.d (Debian/Ubuntu, via /etc/rcN.d +// symlinks), rc-update (Gentoo/OpenRC, via /etc/runlevels), Slackware rc.d +// symlinks, and rc.local (apf's last resort). name is the service base name +// without a ".service" suffix (e.g. "csf", "apf", "netfilter-persistent"); any +// one mechanism reporting it on counts as enabled. +func serviceEnabled(ctx context.Context, name string) bool { + if systemdUnitEnabled(ctx, name) { + return true + } + return sysvServiceEnabled(ctx, name) +} + +// enableService enables name to start at boot under whatever init system is +// active, mirroring how the firewalls' installers register themselves. It is a +// no-op (returns nil) when the service is not installed or already enabled. On +// systemd it runs `systemctl enable` (preceded by daemon-reload so a freshly +// installed unit is picked up); otherwise it uses chkconfig, update-rc.d, or +// rc-update, whichever is present. +func enableService(ctx context.Context, name string) error { + if systemdActive() { + state, present := systemdUnitState(ctx, name) + if !present { + return nil + } + switch state { + case "enabled", "enabled-runtime", "static", "generated": + // static units have no install info and are pulled in by other + // units, so there is nothing to enable. + return nil + } + if _, err := runCommand(ctx, "systemctl", "daemon-reload"); err != nil { + return fmt.Errorf("failed to reload systemd for %s: %s", name, err) + } + if _, err := runCommand(ctx, "systemctl", "enable", name+".service"); err != nil { + return fmt.Errorf("failed to enable %s: %s", name, err) + } + return nil + } + if !serviceInstalled(ctx, name) { + return nil + } + switch { + case commandExists("chkconfig"): + if _, err := runCommand(ctx, "chkconfig", "--add", name); err != nil { + return fmt.Errorf("failed to enable %s: %s", name, err) + } + if _, err := runCommand(ctx, "chkconfig", name, "on"); err != nil { + return fmt.Errorf("failed to enable %s: %s", name, err) + } + case commandExists("update-rc.d"): + if _, err := runCommand(ctx, "update-rc.d", name, "defaults"); err != nil { + return fmt.Errorf("failed to enable %s: %s", name, err) + } + case commandExists("rc-update"): + if _, err := runCommand(ctx, "rc-update", "add", name, "default"); err != nil { + return fmt.Errorf("failed to enable %s: %s", name, err) + } + default: + return fmt.Errorf("no supported init system found to enable %s", name) + } + return nil +} + +// restartService restarts name under whatever init system is active. On systemd +// it runs `systemctl restart`; otherwise it prefers the `service` wrapper, then +// OpenRC's `rc-service`, then the init.d script directly. +func restartService(ctx context.Context, name string) error { + if systemdActive() { + if _, err := runCommand(ctx, "systemctl", "restart", name+".service"); err != nil { + return fmt.Errorf("failed to restart %s: %s", name, err) + } + return nil + } + switch { + case commandExists("service"): + if _, err := runCommand(ctx, "service", name, "restart"); err != nil { + return fmt.Errorf("failed to restart %s: %s", name, err) + } + case commandExists("rc-service"): + if _, err := runCommand(ctx, "rc-service", name, "restart"); err != nil { + return fmt.Errorf("failed to restart %s: %s", name, err) + } + default: + if _, err := runCommand(ctx, filepath.Join("/etc/init.d", name), "restart"); err != nil { + return fmt.Errorf("failed to restart %s: %s", name, err) + } + } + return nil +} diff --git a/ufw_linux.go b/ufw_linux.go index 1b6420e..752b68a 100644 --- a/ufw_linux.go +++ b/ufw_linux.go @@ -9,13 +9,9 @@ import ( "os" "strconv" "strings" - - dbus "github.com/coreos/go-systemd/dbus" ) const ( - // UFWType identifies the ufw backend. - UFWType = "ufw" // UFWIPv4 is ufw's IPv4 user-rules file. UFWIPv4 = "/etc/ufw/user.rules" // UFWIPv6 is ufw's IPv6 user-rules file. @@ -51,19 +47,9 @@ func NewUFW(ctx context.Context, rulePrefix string) (*UFW, error) { ufw := new(UFW) ufw.rulePrefix = rulePrefix - // Connect to systemd dbus interface. - conn, err := dbus.NewWithContext(ctx) - if err != nil { - return nil, fmt.Errorf("failed to connect to systemd: %s", err) - } - defer conn.Close() - - // Find the systemd service for ufw and confirm it was loaded. - prop, err := conn.GetUnitPropertyContext(ctx, "ufw.service", "UnitFileState") - if err != nil { - return nil, fmt.Errorf("error getting ufw service property: %s", err) - } - if prop.Value.Value() != "enabled" { + // Confirm ufw is enabled under whatever init system the host uses + // (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local). + if !serviceEnabled(ctx, "ufw") { return nil, fmt.Errorf("the ufw service is not enabled on this server") } @@ -362,9 +348,14 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) { } } - // Verify the protocol value is valid. + // Resolve the protocol token. ufw's `any` is deferred: on a ported tuple it means + // tcp+udp together (TCPUDP), on a portless tuple it means every IP protocol + // (ProtocolAny), so the ports must be parsed first to tell them apart. A non-`any` + // token must name a known protocol; GetProtocol returns ProtocolAny for an unknown + // value, so an unknown token that is not literally `any` is rejected. + isAny := strings.EqualFold(tokens[1], "any") r.Proto = GetProtocol(tokens[1]) - if r.Proto == ProtocolAny && !strings.EqualFold(tokens[1], "any") { + if r.Proto == ProtocolAny && !isAny { return nil, fmt.Errorf("invalid protocol parameter") } @@ -397,6 +388,14 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) { if err != nil { return nil, err } + + // Resolve ufw's `any` protocol now that the ports are known: a ported `any` tuple + // matches tcp+udp together (TCPUDP), a portless one matches every IP protocol + // (ProtocolAny, the value GetProtocol already assigned). TCPUDP carries ports, + // ProtocolAny does not, so this keeps the two ufw meanings of `any` distinct. + if isAny && (r.HasPorts() || r.HasSourcePorts()) { + r.Proto = TCPUDP + } return } @@ -484,13 +483,9 @@ func (f *UFW) ParseRules(filePath string, family Family) (rules []*Rule, err err 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 +// GetRules returns the existing filter rules from the zone: ufw's own numbered +// tuples from user.rules and user6.rules, then the raw iptables rules from the +// before.rules files, which carry the matches the tuple format cannot express. func (f *UFW) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { // Parse IPv4 user rules. tupleRules, err := f.ParseRules(UFWIPv4, IPv4) @@ -531,20 +526,10 @@ func (f *UFW) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err 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) + // Every row above is reported as ufw stores it. ufw keys its two families into + // separate files and its raw entries into per-family before.rules, so a rule + // spanning both families is two rows; only the ported `any` tuple carries both + // transports in one entry, and UnmarshalRule reads that back as TCPUDP on its own. return } @@ -623,14 +608,35 @@ func (f *UFW) MarshalRule(r *Rule) (string, error) { if r.Log && r.LogPrefix != "" { return "", fmt.Errorf("ufw cannot set a custom log prefix in a tuple") } + // ufw cannot match a port without a port-carrying protocol. Its `any` protocol on + // a ported rule means tcp+udp only (TCPUDP, which carries ports), never every + // protocol: a ProtocolAny port would silently widen the match to ICMP, GRE and the + // rest. PortNeedsConcreteProtocol is true for exactly that case (a port with a + // non-port-carrying protocol) — TCPUDP passes, ProtocolAny with a port is rejected. + if r.PortNeedsConcreteProtocol() { + return "", fmt.Errorf("ufw cannot match a port without a port-carrying protocol (tcp, udp or tcpudp)") + } + // A portless TCPUDP has no single-tuple form: omitting the protocol on a portless + // rule yields ufw's every-protocol match, silently widening tcp+udp to every + // protocol. AddRule fans such a rule out into concrete tcp+udp tuples before it + // reaches here (see tcpudpNeedsExpand), so one arriving at MarshalRule is a caller + // error rather than a shape to mis-encode. + if r.Proto == TCPUDP && !r.HasPorts() && !r.HasSourcePorts() { + return "", fmt.Errorf("ufw cannot express a portless tcp+udp match in a tuple") + } // ufw needs a concrete tcp/udp protocol to match multiple ports (a list or a - // range); a single port may be matched across any protocol. + // range); a single port may be matched across any protocol. TCPUDP is excluded + // here deliberately: ufw itself rejects a bare multiport with "Must specify 'tcp' + // or 'udp' with multiple ports" (backend_iptables.py), so tcp+udp on a port list or + // range has no native tuple form and is fanned out into concrete tcp+udp tuples + // before reaching MarshalRule (see tcpudpNeedsExpand). if r.HasPortSet() && r.Proto != TCP && r.Proto != UDP { return "", fmt.Errorf("ufw requires tcp or udp with multiple ports") } // ufw can match a source port across any protocol for a single port, but (like // a multiport destination) needs a concrete tcp/udp protocol for a list or - // range. A single source port with an unspecified protocol is fine. + // range. A single source port with an unspecified protocol is fine. TCPUDP is + // excluded for the same reason as the destination case above. if r.HasSourcePortSet() && r.Proto != TCP && r.Proto != UDP { return "", fmt.Errorf("ufw requires tcp or udp for a source-port list or range") } @@ -762,8 +768,11 @@ func (f *UFW) MarshalRule(r *Rule) (string, error) { // Add protocol only when an IP address (or a source port, which forces a // from clause) is present; a bare destination-port rule carries its protocol - // in the short form below. - if r.Proto != ProtocolAny && (dstAddr != "" || srcAddr != "") { + // in the short form below. A proto clause is emitted only for a concrete + // protocol: ufw's tuple has no `tcpudp` keyword — it expresses tcp+udp by OMITTING + // the protocol (its `any` protocol on a ported rule) — so TCPUDP emits no proto + // clause, exactly as ProtocolAny does. + if r.Proto != ProtocolAny && r.Proto != TCPUDP && (dstAddr != "" || srcAddr != "") { parts = append(parts, "proto", r.Proto.String()) } @@ -785,10 +794,13 @@ func (f *UFW) MarshalRule(r *Rule) (string, error) { } // If destination port(s) defined and no address on either side, add them in - // ufw's short form. + // ufw's short form. The protocol rides along as `port/proto` for a concrete + // transport, or bare `port` for ufw's `any` protocol — which on a ported rule + // means tcp+udp, i.e. TCPUDP. A ProtocolAny port was rejected up front, so the + // bare form here is reached only by TCPUDP. if r.HasPorts() && dstAddr == "" && srcAddr == "" { val := iptMultiportValue(r.PortSpecs()) - if r.Proto == ProtocolAny { + if r.Proto == TCPUDP { parts = append(parts, val) } else { parts = append(parts, fmt.Sprintf("%s/%s", val, r.Proto.String())) @@ -878,8 +890,8 @@ func (f *UFW) writeIPTablesRulesFile(path string, out []string) error { // editIPTablesRulesFile inserts (or removes) a rule's line(s) in a before.rules // file, just before its COMMIT. A logged rule occupies a LOG line plus an action -// line, coalesced on read and dropped together on remove. It returns whether the -// file was changed. +// line, coalesced on read and dropped together on remove. A removal clears every +// line the rule covers. It returns whether the file was changed. func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove bool) (bool, error) { data, err := os.ReadFile(path) if err != nil { @@ -925,7 +937,11 @@ func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove m.LogPrefix = pendingRule.LogPrefix logical = &m } - if !removed && logical.EqualBase(r, true) { + // Drop every line the target covers, not only the first, so a before.rules + // file holding the rule twice comes back clean in one pass. The target is + // already one concrete family/transport/direction cell — RemoveRule fanned + // out the merged axes — and the file itself pins the family. + if logical.EqualBase(r, true) { removed = true // Only drop the held LOG line when it was this rule's own LOG half // (merged into logical). An unmerged pending line is a separate, @@ -1062,6 +1078,31 @@ func (f *UFW) ruleArgs(r *Rule, verb []string, spec string) []string { return args } +// tcpudpNeedsExpand reports whether a TCPUDP rule cannot be written as a single +// native ufw tuple and must be fanned out into concrete tcp+udp rows. ufw carries +// tcp+udp in one tuple only through its `any` protocol on a ported CLI rule: a +// portless TCPUDP would become ufw's every-protocol match, a multiport TCPUDP is +// rejected by ufw itself ("Must specify 'tcp' or 'udp' with multiple ports"), and a +// TCPUDP rule routed to the before.rules raw path (state, icmp, a non-native limit, +// a negated/ipset address) must reach the iptables marshaller as a concrete +// transport since that path has no both-transports form either. Any of those splits +// into a tcp row and a udp row before the rule is written or removed, mirroring the +// DirAny fan-out. +func (f *UFW) tcpudpNeedsExpand(r *Rule) bool { + if r.Proto != TCPUDP { + return false + } + if f.needsIPTablesRules(r) { + return true + } + // Native only for a single-port `any` tuple: it must carry a port (a portless + // `any` matches every protocol) and match single ports only. + if !r.HasPorts() && !r.HasSourcePorts() { + return true + } + return r.HasPortSet() || r.HasSourcePortSet() +} + // 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 @@ -1075,6 +1116,19 @@ func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error { return nil } + // A TCPUDP rule ufw cannot hold in a single native `any`-proto tuple is fanned out + // into a concrete tcp tuple and a udp tuple, the same way a DirAny rule fans out by + // direction. A native single-port TCPUDP falls through to the CLI path below, where + // MarshalRule emits ufw's bare/`any` short form for it. + if f.tcpudpNeedsExpand(r) { + for _, sub := range expandProtocols(r) { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + // ICMP, connection-state, logging and rate/connection-limit rules are not // expressible through the ufw CLI, so they are written to the iptables-based // before.rules files instead. @@ -1116,33 +1170,39 @@ func (f *UFW) appendRule(ctx context.Context, r *Rule) error { return err } -// nativeInsertPositionFromRows maps a 1-based merged position to ufw's 1-based +// nativeInsertPositionFromRows maps a 1-based logical position to ufw's 1-based // native insert position, given the physical tuple rows in ufw's own order. A nil // row is a tuple ufw counts in its numbered list but this backend does not model (a // route/forward rule); it still occupies a physical slot, so a route rule preceding // the anchor shifts the native position instead of being ignored — which would // place the rule one slot too early per preceding route rule and, for a first-match -// firewall, change enforcement. With no un-representable rows this reduces to the -// plain merged index (position within the representable, merge-collapsed list). +// firewall, change enforcement. Every modeled tuple is its own rule, so with no +// un-representable rows this reduces to the plain physical position. func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int { - var tuples []*Rule var physPos []int for i, r := range rows { if r != nil { - tuples = append(tuples, r) physPos = append(physPos, i+1) } } - repIdx := mergedInsertIndex(mergedFamilyAnchors(tuples), len(tuples), position) - if repIdx >= len(tuples) { + if position < 1 { + position = 1 + } + if position-1 >= len(physPos) { // Past the last logical rule: point past the last physical tuple so ufw // rejects the position and InsertRule falls back to a plain append. return len(rows) + 1 } - return physPos[repIdx] + return physPos[position-1] } -// nativeInsertPosition maps a 1-based merged position (a rule's Number, as +// nativeInsertPosition maps a 1-based logical position (a rule's Number, as GetRules +// reports it) to the 1-based position ufw's own numbered list uses for `ufw insert`. +// The two index spaces differ because ufw counts route rules this backend does not +// model. The tuple order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's +// native order, so the logical position's 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 then handles as a plain append. func (f *UFW) nativeInsertPosition(position int) (int, error) { v4, err := f.parseTupleRows(UFWIPv4, IPv4) if err != nil { @@ -1173,6 +1233,17 @@ func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r * return nil } + // A TCPUDP rule with no single native tuple form is inserted as its concrete + // tcp+udp rows at the same position, mirroring AddRule's fan-out. + if f.tcpudpNeedsExpand(r) { + for _, sub := range expandProtocols(r) { + if err := f.InsertRule(ctx, zoneName, position, sub); err != nil { + return err + } + } + return nil + } + if f.needsIPTablesRules(r) { return f.editIPTablesRules(r, false) } @@ -1185,11 +1256,10 @@ func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r * return err } - // position is a merged Number: GetRules collapses IPv4/IPv6 tuple pairs and - // numbers the result, while `ufw insert` counts ufw's own numbered list, which - // lists every IPv4 tuple then every IPv6 tuple without merging. Map the merged - // position to that native position so a dual-family rule earlier in the list - // does not skew the insert (splitting a pair) when single-family rules coexist. + // position is a Number GetRules reported, which counts only the tuples this + // backend models, while `ufw insert` counts ufw's own numbered list — which also + // counts the route rules it does not model. Map to that native position so a + // route rule earlier in the list does not skew the insert. native, err := f.nativeInsertPosition(position) if err != nil { return err @@ -1212,6 +1282,50 @@ func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r * return err } +// deleteNative marshals r into a ufw rulespec and removes it with `ufw delete`, +// treating an already-absent rule as a no-op (matching every other backend). It is +// the single-tuple removal primitive RemoveRule builds its protocol-axis handling on. +func (f *UFW) deleteNative(ctx context.Context, r *Rule) error { + rule, err := f.MarshalRule(r) + if err != nil { + return err + } + args := f.ruleArgs(r, []string{"delete"}, rule) + if _, err = runCommand(ctx, "ufw", args...); err != nil { + // ufw reports a missing rule as "Could not delete non-existent rule". + if strings.Contains(strings.ToLower(err.Error()), "could not delete") { + return nil + } + return err + } + return nil +} + +// storedNativeTCPUDP returns the single native `any`-proto tuple ufw currently holds +// that backs r — one stored tuple UnmarshalRule read back as TCPUDP (ufw's ported +// `any`) — or nil when none does. A TCPUDP target may instead be backed by a +// separately-added tcp tuple and udp tuple, which delete individually, so the actual +// backing has to be resolved before deleting. It matches on everything but the +// transport (EqualForRemoval). It backs RemoveRule's choice between deleting one +// native tuple (splitting it for a concrete-transport target) and deleting a +// concrete tcp/udp pair, the analog of apf reading its CPORTS list before removal. +func (f *UFW) storedNativeTCPUDP(r *Rule) (*Rule, error) { + v4, err := f.ParseRules(UFWIPv4, IPv4) + if err != nil { + return nil, err + } + v6, err := f.ParseRules(UFWIPv6, IPv6) + if err != nil { + return nil, err + } + for _, e := range append(v4, v6...) { + if e.Proto == TCPUDP && e.EqualForRemoval(r, true) { + return e, nil + } + } + return nil, nil +} + // 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. @@ -1224,24 +1338,58 @@ func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { return nil } + // A TCPUDP rule with no single native tuple form (a raw-path, portless or multiport + // both-transports match) is removed as its concrete tcp+udp rows, mirroring + // AddRule's fan-out. A native single-port TCPUDP falls through to the tuple-backing + // resolution below. + if f.tcpudpNeedsExpand(r) { + for _, sub := range expandProtocols(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + if f.needsIPTablesRules(r) { return f.editIPTablesRules(r, true) } - rule, err := f.MarshalRule(r) - if err != nil { - return err - } - args := f.ruleArgs(r, []string{"delete"}, rule) - if _, err = runCommand(ctx, "ufw", args...); err != nil { - // Removing an already-absent rule is a no-op, matching every other backend. - // ufw reports the miss as "Could not delete non-existent rule". - if strings.Contains(strings.ToLower(err.Error()), "could not delete") { + // Protocol-axis removal. A TCPUDP rule is backed either by a single native + // `any`-proto tuple or by a separately-added tcp tuple + udp tuple, so resolve the + // actual backing before deleting. + if onProtocolAxis(r.Proto) { + native, err := f.storedNativeTCPUDP(r) + if err != nil { + return err + } + if native != nil { + // Backed by a single native `any` tuple. Delete that tuple via its bare/`any` + // form (a TCPUDP marshal of the target). When the target names a single + // transport, re-add the surviving opposite transport so its coverage is kept — + // the protocol analog of splitting a dual-family row on removal + // (splitDualRowProtocol returns nil for a TCPUDP target, dropping the whole row). + del := *r + del.Proto = TCPUDP + if err := f.deleteNative(ctx, &del); err != nil { + return err + } + if s := splitDualRowProtocol(native, r); s != nil { + return f.AddRule(ctx, zoneName, s) + } return nil } - return err + // No native tuple: the rule is backed by concrete-transport tuples. Expand a + // TCPUDP target into tcp+udp and delete each; a concrete target deletes itself. + for _, sub := range expandProtocols(r) { + if err := f.deleteNative(ctx, sub); err != nil { + return err + } + } + return nil } - return nil + + return f.deleteNative(ctx, r) } // MoveRule repositions an existing rule. ufw has no native move verb, so a move @@ -1297,96 +1445,6 @@ func (f *UFW) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) er return nil } -// Backup captures the current filter and NAT rules managed by this backend. -func (f *UFW) 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 default policy and - // managed ipsets; Restore rebuilds them, so every rule read is preserved and - // re-applied. - 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 *UFW) Restore(ctx context.Context, zoneName string, backup *Backup) error { - if backup == nil { - return fmt.Errorf("backup cannot be nil") - } - - // Snapshot the actual current state and remove it, so Restore reconciles the - // live firewall to the backup rather than only re-touching the backup's own - // rules: a rule present now but absent from the backup must be removed. Removal - // of an already-absent rule is tolerated as a no-op (RemoveRule/RemoveNATRule - // are idempotent), so a partially-applied backup can be re-restored cleanly. - current, err := f.GetRules(ctx, zoneName) - if err != nil { - return err - } - currentNAT, err := f.GetNATRules(ctx, zoneName) - if err != nil { - return err - } - for _, r := range current { - // RemoveRule itself dispatches an iptables-file rule to editIPTablesRules, so - // every current rule — whichever form it takes — goes through the same call. - if err := f.RemoveRule(ctx, zoneName, r); err != nil { - return err - } - } - for _, r := range currentNAT { - if err := f.RemoveNATRule(ctx, zoneName, r); err != nil { - return err - } - } - - // Recreate the ipsets now that the current rules are gone (so nothing holds a - // set reference) and before the backup rules that reference them are re-added. - if err := restoreBackupSets(ctx, f, backup, false); err != nil { - return err - } - - // Re-add rules from backup, reproducing their backed-up order. AddRule appends - // an iptables-based rule (inserted before COMMIT in before.rules) but prepends a - // CLI rule (`ufw prepend`, always position 1), so the two groups need opposite - // iteration: append the iptables rules front-to-back, then prepend the CLI rules - // back-to-front so each prepend pushes the earlier rules down and rebuilds the - // original top-to-bottom order. Re-adding CLI rules front-to-back would reverse - // them, inverting first-match evaluation (a specific deny above a broad allow - // would land below it and never fire). - for _, r := range backup.Rules { - if f.needsIPTablesRules(r) { - if err := f.AddRule(ctx, zoneName, r); err != nil { - return err - } - } - } - for i := len(backup.Rules) - 1; i >= 0; i-- { - r := backup.Rules[i] - if f.needsIPTablesRules(r) { - continue - } - 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) -} - // policyKey is the /etc/default/ufw key for a direction's default policy. func (f *UFW) policyKey(d Direction) string { switch d { @@ -1544,6 +1602,96 @@ func (f *UFW) RemoveAddressSetEntry(ctx context.Context, name, entry string) err return f.setHelper().RemoveAddressSetEntry(ctx, name, entry) } +// Backup captures the current filter and NAT rules managed by this backend. +func (f *UFW) 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 default policy and + // managed ipsets; Restore rebuilds them, so every rule read is preserved and + // re-applied. + 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 *UFW) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + + // Snapshot the actual current state and remove it, so Restore reconciles the + // live firewall to the backup rather than only re-touching the backup's own + // rules: a rule present now but absent from the backup must be removed. Removal + // of an already-absent rule is tolerated as a no-op (RemoveRule/RemoveNATRule + // are idempotent), so a partially-applied backup can be re-restored cleanly. + current, err := f.GetRules(ctx, zoneName) + if err != nil { + return err + } + currentNAT, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return err + } + for _, r := range current { + // RemoveRule itself dispatches an iptables-file rule to editIPTablesRules, so + // every current rule — whichever form it takes — goes through the same call. + if err := f.RemoveRule(ctx, zoneName, r); err != nil { + return err + } + } + for _, r := range currentNAT { + if err := f.RemoveNATRule(ctx, zoneName, r); err != nil { + return err + } + } + + // Recreate the ipsets now that the current rules are gone (so nothing holds a + // set reference) and before the backup rules that reference them are re-added. + if err := restoreBackupSets(ctx, f, backup, false); err != nil { + return err + } + + // Re-add rules from backup, reproducing their backed-up order. AddRule appends + // an iptables-based rule (inserted before COMMIT in before.rules) but prepends a + // CLI rule (`ufw prepend`, always position 1), so the two groups need opposite + // iteration: append the iptables rules front-to-back, then prepend the CLI rules + // back-to-front so each prepend pushes the earlier rules down and rebuilds the + // original top-to-bottom order. Re-adding CLI rules front-to-back would reverse + // them, inverting first-match evaluation (a specific deny above a broad allow + // would land below it and never fire). + for _, r := range backup.Rules { + if f.needsIPTablesRules(r) { + if err := f.AddRule(ctx, zoneName, r); err != nil { + return err + } + } + } + for i := len(backup.Rules) - 1; i >= 0; i-- { + r := backup.Rules[i] + if f.needsIPTablesRules(r) { + continue + } + 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) +} + // 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. diff --git a/ufw_linux_test.go b/ufw_linux_test.go index 9beb404..3fbb010 100644 --- a/ufw_linux_test.go +++ b/ufw_linux_test.go @@ -51,7 +51,7 @@ func TestUFWNativeInsertPositionCountsRouteRows(t *testing.T) { require.Equal(t, 3, fw.nativeInsertPositionFromRows(rows, 2), "insert before B lands at B's physical slot 3, not 2") require.Equal(t, 4, fw.nativeInsertPositionFromRows(rows, 3), "past the end appends past the last physical tuple") - // With no un-representable rows the mapping is the plain merged index. + // With no un-representable rows the mapping is the plain physical index. plain := []*Rule{a, b} require.Equal(t, 1, fw.nativeInsertPositionFromRows(plain, 1)) require.Equal(t, 2, fw.nativeInsertPositionFromRows(plain, 2)) @@ -154,13 +154,18 @@ func TestUFWRules(t *testing.T) { require.Equal(t, "allow in proto tcp from 192.168.0.0/24 port 1234 to 0.0.0.0/0 port 22", srcMarshal, "unexpected source-port marshal") - // A single source port with an unspecified protocol is valid (ufw emits - // `proto all ... --sport`); only a source-port list or range needs tcp/udp. - anySrc, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: ProtocolAny, SourcePort: 1234, Action: Accept}) - require.NoError(t, err, "a single source port with proto any must be accepted") + // A single source port needs a port-carrying protocol. TCPUDP (ufw's ported `any`, + // tcp+udp) marshals to the native form with no proto clause; ProtocolAny (every + // protocol) is now rejected, since ufw cannot match a port across every protocol. A + // source-port range still needs a concrete tcp/udp, so a TCPUDP range is rejected. + anySrc, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCPUDP, SourcePort: 1234, Action: Accept}) + require.NoError(t, err, "a single source port with tcpudp must be accepted") require.Contains(t, anySrc, "port 1234") - _, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: ProtocolAny, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}) - require.Error(t, err, "expected a source-port range without tcp/udp to be rejected") + require.NotContains(t, anySrc, "proto", "a tcpudp rule omits the proto clause") + _, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: ProtocolAny, SourcePort: 1234, Action: Accept}) + require.Error(t, err, "a source port across every protocol has no ufw form") + _, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: TCPUDP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}) + require.Error(t, err, "expected a source-port range without a concrete tcp/udp to be rejected") // Test rules we typically set. validRules := []string{ @@ -194,11 +199,15 @@ func TestUFWRules(t *testing.T) { require.Equal(t, "allow in on eth0 proto tcp to 0.0.0.0/0 port 22", spec, "unexpected interface marshal") // Features ufw cannot express in this model are rejected. A multiport match - // without a concrete tcp/udp protocol is also rejected. + // without a concrete tcp/udp protocol is also rejected: ProtocolAny with a port + // means "every protocol on this port" (no ufw form), and a bare multiport — the + // shape a TCPUDP list would take — is rejected by ufw itself, so a TCPUDP port list + // has no single-tuple form either. unsupported := []*Rule{ {Proto: ICMP, Action: Accept}, {Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}, {Proto: ProtocolAny, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, + {Proto: TCPUDP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, } for _, r := range unsupported { _, err := fw.MarshalRule(r) @@ -530,18 +539,6 @@ func TestUFWLimitAndLogTupleParse(t *testing.T) { require.True(t, logged.Log, "an allow_log tuple must be read as a logged rule") } -// The ufw default policy lives in /etc/default/ufw under DEFAULT_*_POLICY keys -// with quoted ACCEPT/DROP/REJECT values (not ufw.conf's *_ACCEPT yes/no). -func TestUFWPolicyKeyAndValue(t *testing.T) { - fw := new(UFW) - require.Equal(t, "DEFAULT_INPUT_POLICY", fw.policyKey(DirInput)) - require.Equal(t, "DEFAULT_OUTPUT_POLICY", fw.policyKey(DirOutput)) - require.Equal(t, "DEFAULT_FORWARD_POLICY", fw.policyKey(DirForward)) - require.Equal(t, `"ACCEPT"`, fw.policyValue(Accept)) - require.Equal(t, `"DROP"`, fw.policyValue(Drop)) - require.Equal(t, `"REJECT"`, fw.policyValue(Reject), "ufw supports a reject default policy") -} - // ufw's native `limit` action can carry logging (`ufw limit log ...` -> a // `limit_log` tuple in user.rules). Such a rule must stay on the tuple path, not // be routed to before.rules, or Restore removes it from the wrong file and @@ -626,3 +623,121 @@ func TestUFWParseRulesFile(t *testing.T) { require.Equal(t, "dns", rules[1].Comment, "the configured prefix is split off the comment") require.True(t, rules[1].HasPrefix, "the prefixed comment marks the rule as ours") } + +// ufw's `any` protocol means two different things depending on whether the rule +// carries a port: on a ported rule it is tcp+udp together (TCPUDP, ufw's native +// both-transports form), and on a portless rule it is every IP protocol +// (ProtocolAny). TestUFWTCPUDPMarshal exercises the write side of that split. +func TestUFWTCPUDPMarshal(t *testing.T) { + fw := new(UFW) + + // A single-port TCPUDP rule emits ufw's bare-port short form (proto rides along as + // ufw's `any`, so no `/proto` suffix and no proto clause). + spec, err := fw.MarshalRule(&Rule{Port: 80, Proto: TCPUDP, Action: Accept}) + require.NoError(t, err) + require.Equal(t, "allow in 80", spec, "a single-port TCPUDP rule uses ufw's bare-port short form") + + // The same port with ProtocolAny now errors: a port across every protocol has no + // ufw form. + _, err = fw.MarshalRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}) + require.Error(t, err, "a ProtocolAny rule carrying a port has no ufw form") + + // A portless ProtocolAny rule is ufw's real `any` and still marshals fine. + spec, err = fw.MarshalRule(&Rule{Proto: ProtocolAny, Source: "1.2.3.4", Action: Accept}) + require.NoError(t, err, "a portless ProtocolAny (ufw's real `any`) must marshal") + require.Contains(t, spec, "from 1.2.3.4") + require.NotContains(t, spec, "proto", "ufw's `any` protocol carries no proto clause") + + // A family-specific single-port TCPUDP rule uses the full grammar with no proto + // clause (ufw's `any` on a ported rule), covering both tcp and udp. + spec, err = fw.MarshalRule(&Rule{Family: IPv4, Port: 80, Proto: TCPUDP, Action: Accept}) + require.NoError(t, err) + require.Equal(t, "allow in to 0.0.0.0/0 port 80", spec) + + // A portless TCPUDP has no single-tuple form (it would widen to every protocol) and + // is rejected by MarshalRule; AddRule fans it out instead. + _, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: TCPUDP, Action: Accept}) + require.Error(t, err, "a portless TCPUDP match cannot be a single ufw tuple") + require.True(t, fw.tcpudpNeedsExpand(&Rule{Family: IPv4, Proto: TCPUDP, Action: Accept}), + "a portless TCPUDP rule must fan out into concrete tcp+udp tuples") + require.True(t, fw.tcpudpNeedsExpand(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}), + "a multiport TCPUDP rule must fan out into concrete tcp+udp tuples") + require.False(t, fw.tcpudpNeedsExpand(&Rule{Port: 80, Proto: TCPUDP, Action: Accept}), + "a single-port TCPUDP rule uses ufw's native both-transports tuple") +} + +// TestUFWTCPUDPTupleRoundTrip verifies the read side of ufw's `any`-protocol split: +// an `any` tuple with a port decodes to TCPUDP, one without a port to ProtocolAny. +func TestUFWTCPUDPTupleRoundTrip(t *testing.T) { + fw := new(UFW) + + // A ported `any` tuple is tcp+udp together. + ported, err := fw.UnmarshalRule("allow any 80 0.0.0.0/0 any 0.0.0.0/0 in", IPv4) + require.NoError(t, err) + require.Equal(t, TCPUDP, ported.Proto, "a ported `any` tuple decodes to TCPUDP") + require.EqualValues(t, 80, ported.Port) + + // A source-port-only `any` tuple is likewise tcp+udp. + sported, err := fw.UnmarshalRule("allow any any 0.0.0.0/0 1024 0.0.0.0/0 in", IPv4) + require.NoError(t, err) + require.Equal(t, TCPUDP, sported.Proto, "an `any` tuple with a source port decodes to TCPUDP") + require.True(t, sported.HasSourcePorts(), "the source port must be parsed") + require.EqualValues(t, 1024, sported.SourcePort) + + // A portless `any` tuple is every IP protocol. + portless, err := fw.UnmarshalRule("allow any any 0.0.0.0/0 any 1.2.3.4 in", IPv4) + require.NoError(t, err) + require.Equal(t, ProtocolAny, portless.Proto, "a portless `any` tuple decodes to ProtocolAny") + require.Equal(t, "1.2.3.4", portless.Source) + + // A native single-port TCPUDP rule round-trips through marshal + unmarshal. + spec, err := fw.MarshalRule(&Rule{Family: IPv4, Port: 443, Proto: TCPUDP, Action: Accept}) + require.NoError(t, err) + require.Equal(t, "allow in to 0.0.0.0/0 port 443", spec) +} + +// A separately-added tcp/80 tuple and udp/80 tuple stay two rules on read — ufw stores +// them as two tuples — but they cover the TCPUDP rule between them, so a caller (and +// Sync) sees the port as already open on both transports. +func TestUFWSeparateTransportTuplesCoverTCPUDP(t *testing.T) { + stored := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 80, Action: Accept}, + {Family: IPv4, Proto: UDP, Port: 80, Action: Accept}, + } + both := &Rule{Family: IPv4, Proto: TCPUDP, Port: 80, Action: Accept} + require.True(t, both.CoveredBy(stored)) + require.False(t, both.CoveredBy(stored[:1]), "the tcp tuple alone leaves udp uncovered") + + // A tcp/80 and udp/443 pair covers neither port on both transports. + require.False(t, both.CoveredBy([]*Rule{ + {Family: IPv4, Proto: TCP, Port: 80, Action: Accept}, + {Family: IPv4, Proto: UDP, Port: 443, Action: Accept}, + }), "differing ports must not be read as cross-transport coverage") +} + +// Every modeled tuple is its own rule, so a logical position maps straight to ufw's +// native slot. An unmodeled tuple (a route rule, kept as a nil row) still occupies a +// physical slot and shifts the ones after it. +func TestUFWNativeInsertPositionSkipsUnmodeledRows(t *testing.T) { + fw := new(UFW) + tcp80 := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept} + udp80 := &Rule{Family: IPv4, Proto: UDP, Port: 80, Action: Accept} + tcp22 := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept} + + // With every tuple modeled, position N is native slot N. + rows := []*Rule{tcp80, udp80, tcp22} + require.Equal(t, 1, fw.nativeInsertPositionFromRows(rows, 1)) + require.Equal(t, 2, fw.nativeInsertPositionFromRows(rows, 2)) + require.Equal(t, 3, fw.nativeInsertPositionFromRows(rows, 3)) + require.Equal(t, 4, fw.nativeInsertPositionFromRows(rows, 4), + "past the end appends past the last physical tuple") + + // A route rule ufw counts but this backend cannot model sits at physical slot 2, + // so the second reported rule lives at slot 3. + withRoute := []*Rule{tcp80, nil, udp80, tcp22} + require.Equal(t, 1, fw.nativeInsertPositionFromRows(withRoute, 1)) + require.Equal(t, 3, fw.nativeInsertPositionFromRows(withRoute, 2), + "the unmodeled route tuple shifts the native slot") + require.Equal(t, 4, fw.nativeInsertPositionFromRows(withRoute, 3)) + require.Equal(t, 5, fw.nativeInsertPositionFromRows(withRoute, 4)) +} diff --git a/wf_windows.go b/wf_windows.go index 041031b..de227b2 100644 --- a/wf_windows.go +++ b/wf_windows.go @@ -11,8 +11,6 @@ import ( ) const ( - // WFType is the backend type string reported by WF.Type. - WFType = "windows-firewall" // The IP protocol numbers for the transport/tunnel protocols the model adds. // Windows filters by raw protocol number, so these map directly. wfProtocolGRE = 47 @@ -449,17 +447,25 @@ func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err rules = append(rules, r) } - // Collapse each inbound rule and its outbound twin into one DirAny rule. WFP - // stores an inbound and an outbound filter as separate objects; the local/remote - // swap the marshal path applies means a both-directions allow reads back as an - // input (source) rule plus an output (destination) rule, which merge here. - rules = mergeDirections(rules) - + // Every filter above is reported as WFP stores it. WFP has no per-rule family + // selector, so a FamilyAny rule is one dual-family filter and reads back as + // FamilyAny on its own. Its protocol field carries a single protocol number and + // its direction field a single direction, so a TCPUDP rule is a tcp filter plus a + // udp filter and a DirAny rule an inbound plus an outbound filter — each reported + // as its own rule. return rules, nil } // MarshallFWRule encodes a Rule as a Windows FWRule for the given zone. func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) { + // A WFP filter carries one protocol number, so the TCPUDP protocol has + // no single-filter form; AddRule/RemoveRule fan it into a tcp filter and a udp + // filter with expandProtocols before reaching here. A TCPUDP rule at this row + // level means that fan-out was skipped, so reject it with a clear message before + // the tcp/udp-only port check below can reject it more obscurely. + if err := r.CheckExpandedProtocol(); err != nil { + return nil, err + } // The Windows Firewall rule model has only inbound and outbound directions; // forwarded (routed) traffic is handled out of band (RRAS/portproxy), so a // forward rule cannot be expressed here. @@ -672,6 +678,17 @@ func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error { return fmt.Errorf("rule cannot be nil") } + // A TCPUDP rule fans out into a tcp filter plus a udp filter; a WFP filter + // carries one protocol number, so it has no single-filter form. + if r.Proto == TCPUDP { + for _, sub := range expandProtocols(r) { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + // A DirAny rule fans out into an inbound filter plus its role-swapped outbound // filter; WFP stores each direction as its own rule object. if r.Direction == DirAny { @@ -738,6 +755,18 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { return err } + // A TCPUDP target removes both its tcp filter and its udp filter; WFP stores only + // concrete-protocol filters, so removing the tcp half leaves the udp filter and + // vice versa (the surviving transport re-adds nothing, unlike a container backend). + if r.Proto == TCPUDP { + for _, sub := range expandProtocols(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + // A DirAny target removes both its inbound and its role-swapped outbound filter. if r.Direction == DirAny { for _, sub := range expandDirections(r) { @@ -823,35 +852,6 @@ func (f *WF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) err 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. func (f *WF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { return nil, unsupportedPolicy(f.Type()) @@ -892,6 +892,35 @@ 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 +} + +// 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 +} + // Reload is a no-op; Windows Firewall applies rule changes immediately. func (f *WF) Reload(ctx context.Context) error { return nil diff --git a/wf_windows_test.go b/wf_windows_test.go index d0fafdb..9b60684 100644 --- a/wf_windows_test.go +++ b/wf_windows_test.go @@ -1,7 +1,6 @@ package firewall import ( - "context" "testing" wapi "github.com/iamacarpet/go-win64api" @@ -113,7 +112,62 @@ func TestWFProtocolAndComment(t *testing.T) { // rejected with the ErrUnsupportedForward sentinel. _, err = fw.MarshallFWRule("", &Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept}) require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected") - require.False(t, fw.Capabilities().Forward, "windows firewall does not advertise forward support") +} + +// TestWFTCPUDPProtocol verifies the merged TCPUDP protocol axis. A WFP filter +// carries one protocol number, so a TCPUDP rule has no single-filter form: it must +// be fanned out into a tcp filter and a udp filter on write and collapse back on +// read. The row-level marshaller rejects a TCPUDP rule with a clear message, before +// the more obscure tcp/udp-only port check, and ProtocolAny+ports stays rejected. +func TestWFTCPUDPProtocol(t *testing.T) { + fw := &WF{rulePrefix: "test"} + + // A TCPUDP rule reaching the row-level marshaller is rejected: the fan-out was + // skipped. The guard fires before the port checks so its message wins. + _, err := fw.MarshallFWRule("", &Rule{Proto: TCPUDP, Port: 80, Action: Accept}) + require.Error(t, err, "a TCPUDP rule must be rejected at the row-level marshaller") + require.Contains(t, err.Error(), "tcpudp", "the guard message must name the tcpudp protocol") + + // A portless TCPUDP rule is rejected for the same reason. + _, err = fw.MarshallFWRule("", &Rule{Proto: TCPUDP, Action: Accept}) + require.Error(t, err, "a portless TCPUDP rule must still be rejected") + + // ProtocolAny with a port stays rejected: ProtocolAny matches every IP protocol + // and carries no ports, so the port cannot be honored. + _, err = fw.MarshallFWRule("", &Rule{Proto: ProtocolAny, Port: 80, Action: Accept}) + require.Error(t, err, "ProtocolAny with a port must be rejected") + + // SCTP with a port stays rejected with the ErrUnsupported sentinel: WFP matches + // ports only for tcp and udp. + _, err = fw.MarshallFWRule("", &Rule{Proto: SCTP, Port: 9000, Action: Accept}) + require.ErrorIs(t, err, ErrUnsupported, "a port on SCTP must be rejected as unsupported") + + // expandProtocols fans a TCPUDP rule into a tcp row and a udp row, each of which + // marshals cleanly and reads back as its concrete transport. + base := &Rule{Proto: TCPUDP, Port: 22, Action: Accept} + subs := expandProtocols(base) + require.Len(t, subs, 2, "a TCPUDP rule fans into two concrete-transport rows") + gotProtos := map[Protocol]bool{} + for _, sub := range subs { + fr, err := fw.MarshallFWRule("", sub) + require.NoError(t, err, "an expanded row must marshal: %+v", *sub) + parsed := fw.UnmarshallFWRule(*fr) + require.NotNil(t, parsed, "an expanded row must round-trip: %+v", *sub) + require.True(t, parsed.Equal(sub, true), "round-trip mismatch: %+v vs %+v", *sub, parsed) + gotProtos[parsed.Proto] = true + } + require.True(t, gotProtos[TCP] && gotProtos[UDP], "the fan-out must yield one tcp and one udp row") + + // The two filters stay two rules on read — a WFP filter carries one protocol + // number, so that is the firewall's actual state — but together they cover the + // TCPUDP rule that was written, so Sync does not re-add it. + stored := []*Rule{ + {Proto: TCP, Port: 22, Action: Accept}, + {Proto: UDP, Port: 22, Action: Accept}, + } + both := &Rule{Proto: TCPUDP, Port: 22, Action: Accept} + require.True(t, both.CoveredBy(stored), "the tcp+udp filter pair covers the TCPUDP rule") + require.False(t, both.CoveredBy(stored[:1]), "the tcp filter alone leaves udp uncovered") } // A named ICMPv6 type must resolve through the ICMPv6 table, not the ICMPv4 one: @@ -134,22 +188,6 @@ func TestWFICMPv6NamedType(t *testing.T) { require.Equal(t, uint8(128), *r.ICMPType, "ICMPv6 echo-request is type 128, not the ICMPv4 value 8") } -func TestWFUnsupportedLogLimitAndNAT(t *testing.T) { - ctx := context.Background() - fw := &WF{rulePrefix: "test"} - - require.Error(t, fw.AddRule(ctx, "", &Rule{Port: 22, Proto: TCP, Action: Accept, Log: true}), - "WFP should reject logging") - require.Error(t, fw.AddRule(ctx, "", &Rule{Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}), - "WFP should reject rate limiting") - - nat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "1.2.3.4", ToPort: 8080} - require.Error(t, fw.AddNATRule(ctx, "", nat), "WFP should reject NAT") - require.Error(t, fw.RemoveNATRule(ctx, "", nat), "WFP should reject NAT") - _, err := fw.GetNATRules(ctx, "") - require.Error(t, err, "WFP should reject NAT listing") -} - // decodeAddress must surface a Windows rule that carries a comma-separated // address list (built-in rules commonly do) by decoding its first entry, rather // than erroring and letting UnmarshallFWRule drop the whole rule.