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.