Add TCPUDP protocol, coverage relation, and drop read-side merging

Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged
value spanning both transports, distinct from ProtocolAny (which matches
every IP protocol and carries no port). Backends whose native syntax holds
both transports in one row (nftables, ufw, apf) store and read it as one
rule; the rest fan it out with expandProtocols. Removing one transport of a
merged row splits it via splitMergedRow, which composes the family and
protocol splits so an nftables row merged on both axes leaves a correct,
non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT.

Remove read-side merging. GetRules now reports the firewall's actual rows
and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up
separately-stored ones, so mergeFamilies, mergeDirections and their helpers
are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead
compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the
NATRule pair) expand a rule across family, transport and direction and decide
containment cell by cell, which is what lets Sync stay a no-op against its
own output whichever representation a backend chose.

Extract the systemd/SysV service helpers out of the iptables backend into
services.go so every Linux backend shares one implementation, and document
the multi-state rule model and the coverage helpers in the README.
This commit is contained in:
James Coleman 2026-07-09 17:52:19 -05:00
parent 073c9ad7f0
commit a036c8e6e9
27 changed files with 4238 additions and 2690 deletions

View file

@ -141,9 +141,9 @@ reference.
| `Family` | `FamilyAny`, `IPv4`, or `IPv6`. | | `Family` | `FamilyAny`, `IPv4`, or `IPv6`. |
| `Source` | Source address/CIDR. Prefix with `!` to negate, where supported. | | `Source` | Source address/CIDR. Prefix with `!` to negate, where supported. |
| `Destination` | Destination 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. | | `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). | | `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`). | | `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`. | | `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`. | | `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. | | `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 `Capabilities().Output` reports whether a backend distinguishes input from output
into a single rule when reading rules back. `Capabilities().Output` reports whether (firewalld, for example, does not), and `Capabilities().Forward` reports whether it
a backend distinguishes input from output (firewalld, for example, does not), and can express a forward-chain (routing) rule. A `DirForward` rule on a backend without
`Capabilities().Forward` reports whether it can express a forward-chain (routing) forward support is rejected with `ErrUnsupportedForward`.
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 ### `DirAny` — both directions
@ -176,12 +199,12 @@ So `DirAny` with `Source: X` matches inbound traffic *from* `X` and outbound
traffic *to* `X`. traffic *to* `X`.
- **On add**, a `DirAny` rule fans out into a concrete input row plus its swapped - **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 output row, except on csf/apf, whose bare-host `csf.allow`/`allow_hosts` line is
back into one `DirAny` rule (the same way a v4/v6 pair collapses to `FamilyAny`). 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 - **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 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 bidirectional plain line and re-express the surviving direction through their
direction through their raw-iptables hook. raw-iptables hook.
- On csf/apf, a bare (address-only, no-port) `DirAny` host allow is the single - 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 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 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 `DirAny` rule degrades to its input half (`DirInput`, same fields) rather than
being rejected. 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 (port forwarding and masquerade)
NAT rules are managed separately from filter rules through NAT rules are managed separately from filter rules through

View file

@ -8,12 +8,9 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
dbus "github.com/coreos/go-systemd/dbus"
) )
const ( const (
APFType = "apf"
APFConf = "/etc/apf/conf.apf" APFConf = "/etc/apf/conf.apf"
APFAllow = "/etc/apf/allow_hosts.rules" APFAllow = "/etc/apf/allow_hosts.rules"
APFDeny = "/etc/apf/deny_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. // APF manages the firewall through apf's config files and a managed pre-hook.
type APF struct { type APF struct {
ConfigChanged bool ConfigChanged bool
// rulePrefix is prepended to a rule's comment when it is written as a // rulePrefix tags rules this library creates so they can be told apart
// full-line comment above a rule in allow_hosts.rules/deny_hosts.rules, so // from foreign rules. In allow_hosts.rules/deny_hosts.rules it is
// rules this library creates can be told apart. conf.apf port/icmp-list // prepended to the comment written on the line above each rule;
// rules carry no per-rule comment and so ignore it. // conf.apf port/icmp-list rules carry no per-rule comment and so
// cannot carry the tag.
rulePrefix string rulePrefix string
// ipv6Enabled mirrors conf.apf's USE_IPV6. apf's own shell logic silently // ipv6Enabled mirrors conf.apf's USE_IPV6. With it off (the shipped default) apf
// no-ops a bare IPv6 host in allow_hosts.rules/deny_hosts.rules // enforces no IPv6 at all: its shell logic no-ops a bare IPv6 host in
// (apf_trust.sh trust_hosts()) and the native IG_ICMPV6_TYPES/EG_ICMPV6_TYPES // allow_hosts.rules/deny_hosts.rules (apf_trust.sh trust_hosts()) and the native
// lists (cports.common) whenever USE_IPV6 is not "1" (the shipped default), // IG_ICMPV6_TYPES/EG_ICMPV6_TYPES lists (cports.common), and ip6tables is never
// so AddRule must reject those shapes rather than write a rule apf will // flushed on (re)load (apf_ipt.sh ipt6()). AddRule therefore rejects every
// never enforce. // concrete-IPv6 rule (see ipv6Unavailable) rather than write one apf will never
// enforce.
ipv6Enabled bool ipv6Enabled bool
} }
@ -53,49 +52,10 @@ func NewAPF(ctx context.Context, rulePrefix string) (*APF, error) {
apf := new(APF) apf := new(APF)
apf.rulePrefix = rulePrefix apf.rulePrefix = rulePrefix
// Connect to systemd dbus interface. // Confirm apf is enabled under whatever init system the host uses
var prop *dbus.Property // (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local).
conn, err := dbus.NewWithContext(ctx) if !serviceEnabled(ctx, "apf") {
if err == nil { return nil, fmt.Errorf("the apf service is not active or enabled on this server")
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 config files exist. // Confirm config files exist.
@ -127,12 +87,11 @@ func (f *APF) Capabilities() Capabilities {
return Capabilities{ return Capabilities{
Output: true, Output: true,
Forward: true, Forward: true,
// ICMPv6 mirrors ipv6Enabled: apf's native IG_ICMPV6_TYPES/EG_ICMPV6_TYPES // ICMPv6 mirrors ipv6Enabled: with conf.apf's USE_IPV6 off, apf never touches
// lists (what a plain, qualifier-free ICMPv6 rule uses) are a silent no-op // ip6tables, so neither its native config nor the raw-iptables hook yields a
// on the real firewall whenever conf.apf's USE_IPV6 is not "1" (confirmed // 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 // 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/ // IG_ICMPV6_TYPES when USE_IPV6=0.
// log/rate matching still works via the raw-iptables hook regardless.
ICMPv6: f.ipv6Enabled, ICMPv6: f.ipv6Enabled,
PortList: false, PortList: false,
ConnState: true, ConnState: true,
@ -335,6 +294,18 @@ func (f *APF) stopKey(proto Protocol) string {
// denyActionFor reads conf.apf's setting for the given protocol (see // denyActionFor reads conf.apf's setting for the given protocol (see
func (f *APF) denyActionFor(proto Protocol) Action { 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)) return f.readStopAction(APFConf, f.stopKey(proto))
} }
@ -350,8 +321,16 @@ func (f *APF) resolveAction(base Action, proto Protocol) Action {
return base return base
} }
switch proto { switch proto {
case TCP, UDP: case TCP, UDP, TCPUDP:
return f.denyActionFor(proto) // 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: default:
return base 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 // The action depends on the protocol just parsed (see resolveAction), so
// it is resolved last rather than stamped up front. // it is resolved last rather than stamped up front.
r.Action = f.resolveAction(action, r.Proto) 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...) rules = append(rules, hookRules...)
// Merge rules across families so a v4/v6 hook pair collapses to one rule, then // Every entry above is reported as apf stores it, and several apf entries cover
// collapse each input/output twin into one DirAny rule — a plain allow_hosts/ // more than one axis on their own: a CPORTS or CLIMIT entry is dual-stack and
// deny_hosts IP, read as an inbound (source) rule plus an outbound (destination) // decodes to FamilyAny, a protocol-less advanced line decodes to TCPUDP, and a
// rule, reads back as the single bidirectional line it was written as. // bare allow_hosts/deny_hosts IP is one bidirectional line that decodes to DirAny.
rules = mergeFamilies(rules) // What apf keys separately — the TCP and UDP CPORTS lists, the IG_ and EG_ prefixes,
rules = mergeDirections(rules) // the per-family hook lines — stays several rules.
return 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 // Determine which config list this key manages and the tokens the rule
// contributes to it. Non-matching keys are returned untouched. // 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 var wantTokens []string
switch key { switch key {
case "IG_TCP_CPORTS": case "IG_TCP_CPORTS":
if r.IsOutput() || r.Proto != TCP { if r.IsOutput() || !coversProtocol(r.Proto, TCP) {
return orig return orig
} }
wantTokens = f.portTokens(r) wantTokens = f.portTokens(r)
case "IG_UDP_CPORTS": case "IG_UDP_CPORTS":
if r.IsOutput() || r.Proto != UDP { if r.IsOutput() || !coversProtocol(r.Proto, UDP) {
return orig return orig
} }
wantTokens = f.portTokens(r) wantTokens = f.portTokens(r)
case "EG_TCP_CPORTS": case "EG_TCP_CPORTS":
if !r.IsOutput() || r.Proto != TCP { if !r.IsOutput() || !coversProtocol(r.Proto, TCP) {
return orig return orig
} }
wantTokens = f.portTokens(r) wantTokens = f.portTokens(r)
case "EG_UDP_CPORTS": case "EG_UDP_CPORTS":
if !r.IsOutput() || r.Proto != UDP { if !r.IsOutput() || !coversProtocol(r.Proto, UDP) {
return orig return orig
} }
wantTokens = f.portTokens(r) 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") 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 var parts []string
switch r.Proto { switch r.Proto {
case TCP: case TCP:
parts = append(parts, "tcp") parts = append(parts, "tcp")
case UDP: case UDP:
parts = append(parts, "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() { if r.IsOutput() {
parts = append(parts, "out") 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- // 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 // 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 // the placeholder matching the rule's family. A family-neutral rule has
// no single literal, so cover both families — mergeFamilies collapses // no single literal, so emit a line per family rather than let the rule
// the v4/v6 pair back to FamilyAny on read, keeping the rule both // silently become IPv4-only; each line reads back as its own rule.
// readable and removable rather than silently becoming IPv4-only.
writeAny := func(placeholder string) { writeAny := func(placeholder string) {
var tokens []string var tokens []string
if r.Proto != ProtocolAny { if r.Proto != TCPUDP {
tokens = append(tokens, r.Proto.String()) tokens = append(tokens, r.Proto.String())
} }
if r.IsOutput() { if r.IsOutput() {
@ -1269,30 +1267,14 @@ func (f *APF) nativeICMPv6(r *Rule) bool {
!r.Log && r.RateLimit == nil && f.isConfRule(r) !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 — // 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 // 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 // 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 // decision (dualStackPortNeedsHook) and the remove-time split/clear
// (removeDualStackPort) build on it. // (removeDualStackPort) build on it.
func (f *APF) barePortAccept(r *Rule) bool { 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 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 // 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); // address-less accept (isConfRule, carried by the IG_*_CPORTS comma lists);
// every other list goes to the hook's iptables multiport match. // 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) { (len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1) && !f.isConfRule(r) {
return true return true
} }
// A source-port match with no address has no advanced-rule form (advanced rules // 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. // require an address); iptables matches --sport directly, so it goes to the hook.
if r.HasSourcePorts() && r.Source == "" && r.Destination == "" && if r.HasSourcePorts() && r.Source == "" && r.Destination == "" &&
(r.Proto == TCP || r.Proto == UDP) { onProtocolAxis(r.Proto) {
return true return true
} }
// A connection limit conf.apf's IG_*_CLIMIT cannot express — anything but a // 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 // prior state, including inert entries the gate would reject as fresh no-op
// writes. // writes.
func (f *APF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6Gate bool) error { 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; // 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 // 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). // 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 f.ConfigChanged = f.ConfigChanged || changed
return err 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 { if err := iptablesRuleValid(r); err != nil {
return fmt.Errorf("%v: %w", err, ErrUnsupported) return fmt.Errorf("%v: %w", err, ErrUnsupported)
} }
@ -1517,25 +1505,44 @@ func (f *APF) cPortsKey(proto Protocol, output bool) string {
return "" 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 // 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 // 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 // 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 // in — the rule view alone cannot tell a genuine CPORTS entry from a pair of
// pair of single-family hook rules — and split it when present: drop the dual-stack // 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 // 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. // family keeps its coverage. Otherwise the rule is a per-family hook rule.
func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error { 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 { if err != nil {
return err return err
} }
inCPorts := false
for _, e := range f.ParsePorts(val, r.Proto, r.IsOutput()) {
if e.EqualForRemoval(r, true) {
inCPorts = true
break
}
}
if inCPorts { if inCPorts {
// Drop the dual-stack CPORTS entry (family-agnostic), then re-add the // Drop the dual-stack CPORTS entry (family-agnostic), then re-add the
// surviving opposite family through the hook. // 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 // 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), // 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 // in a v4+v6 pair in the hook (two separate concrete-family adds), or split across
// back into one FamilyAny rule), or split across both. The merged read cannot tell // both. A read cannot tell which, so remove the rule from both backings — EditConf
// which, so remove the rule from both backings — EditConf drops it from the CPORTS // drops it from the CPORTS list and the hook edit drops both per-family rows, and
// list and the hook edit drops both per-family rows, and each no-ops when the rule is // each no-ops when the rule is absent — clearing every cell the target covers,
// absent — clearing the whole merged rule wherever it lives. Unlike the single-family // wherever it lives. Unlike the single-family
func (f *APF) removeFamilyAnyPort(ctx context.Context, r *Rule) error { func (f *APF) removeFamilyAnyPort(ctx context.Context, r *Rule) error {
if err := f.EditConf(ctx, r, true); err != nil { if err := f.EditConf(ctx, r, true); err != nil {
return err return err
@ -1621,8 +1628,8 @@ func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return f.removeDualStackPort(ctx, r) return f.removeDualStackPort(ctx, r)
} }
// A FamilyAny bare tcp/udp port accept is stored in a dual-stack CPORTS entry, in a // 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 // v4+v6 hook pair (separate concrete-family adds), or split across both;
// across both; removeFamilyAnyPort clears it from every backing (see there). // removeFamilyAnyPort clears it from every backing (see there).
if f.barePortAccept(r) { if f.barePortAccept(r) {
return f.removeFamilyAnyPort(ctx, 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 // 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 // derives HasPrefix from it (an empty prefix writes no tag, so HasPrefix is
// false; a rule hand-added without the tag likewise reports false). // false; a rule hand-added without the tag likewise reports false).
merged := mergeNATFamilies(rules) return rules, nil
return merged, nil
} }
// natFamilies lists the address families a NAT rule is written for: a rule // 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) 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. // Backup captures the current filter and NAT rules managed by this backend.
func (f *APF) Backup(ctx context.Context, zoneName string) (*Backup, error) { func (f *APF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName) rules, err := f.GetRules(ctx, zoneName)
@ -1964,68 +2031,6 @@ func (f *APF) Restore(ctx context.Context, zoneName string, backup *Backup) erro
return nil 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. // Reload restarts apf to apply config changes, but only when a mutation changed its files.
func (f *APF) Reload(ctx context.Context) error { func (f *APF) Reload(ctx context.Context) error {
// apf --restart rewrites and reloads the whole ruleset, which is disruptive, so // apf --restart rewrites and reloads the whole ruleset, which is disruptive, so

View file

@ -125,11 +125,11 @@ func TestAPFDualStackPort(t *testing.T) {
} }
// TestAPFBarePortAccept locks in the removal routing shared by single-family and // 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 // FamilyAny bare tcp/udp port accepts. A FamilyAny port target has impliedFamily
// pair reads as impliedFamily FamilyAny, so dualStackPortNeedsHook (single-family // FamilyAny, so dualStackPortNeedsHook (single-family only) does not match it;
// only) does not match it; barePortAccept must, so RemoveRule routes it to the // barePortAccept must, so RemoveRule routes it to the family-agnostic
// family-agnostic removeFamilyAnyPort instead of the native-only EditConf path that // removeFamilyAnyPort instead of the native-only EditConf path that cannot clear the
// cannot clear the hook rows. Regression for mergedfamilyremove leaving the port open. // hook rows. Regression for a family-agnostic removal leaving the port open.
func TestAPFBarePortAccept(t *testing.T) { func TestAPFBarePortAccept(t *testing.T) {
fw := new(APF) fw := new(APF)
// Both a concrete-family and a FamilyAny bare port accept are bare-port accepts; // 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})) 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) { func TestAPFIPListComment(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "allow_hosts.rules") 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 // 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 // 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 // (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 // 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 // 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, os.WriteFile(deny, nil, 0o644))
require.NoError(t, fw.EditIPList(ctx, deny, Drop, rule, false)) 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) got, err := fw.ParseIPList(deny, Drop)
require.NoError(t, err) require.NoError(t, err)
got = mergeFamilies(got) require.Len(t, got, wantRows, "port-only deny (%s) must round-trip to %d row(s)", rule.Family, wantRows)
require.Len(t, got, 1, "port-only deny (%s) must round-trip to one rule", rule.Family) require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule; want family=%s", 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) 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). // It must also be removable (matched back on delete).
require.NoError(t, fw.EditIPList(ctx, deny, Drop, rule, true)) 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.Equal(t, `IG_TCP_CLIMIT="80:25"`, out)
require.False(t, fw.ConfigChanged, "an unchanged connlimit count must not set ConfigChanged") 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}))
}

View file

@ -8,16 +8,10 @@ import (
"strings" "strings"
) )
// This file makes a Backup portable: it can be serialized to and from JSON so a // Backup JSON serialization so snapshots can be persisted to disk or moved
// snapshot taken on one host can be replayed on another (or persisted to disk). // 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 rule/NAT-rule structs are plain data, but their enum fields (Action, // the per-type boilerplate to one line.
// 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.
// marshalEnum renders an enum value as its canonical string name. // marshalEnum renders an enum value as its canonical string name.
func marshalEnum[T ~uint8](v T, str func(T) string) ([]byte, error) { func marshalEnum[T ~uint8](v T, str func(T) string) ([]byte, error) {

View file

@ -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)
}

View file

@ -9,12 +9,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
dbus "github.com/coreos/go-systemd/dbus"
) )
const ( const (
CSFType = "csf"
CSFConf = "/etc/csf/csf.conf" CSFConf = "/etc/csf/csf.conf"
CSFAllow = "/etc/csf/csf.allow" CSFAllow = "/etc/csf/csf.allow"
CSFDeny = "/etc/csf/csf.deny" CSFDeny = "/etc/csf/csf.deny"
@ -35,17 +32,17 @@ const (
// rules onto its config files (csf.conf, csf.allow, csf.deny, csf.redirect) and // 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. // a managed pre-hook for features csf's native config cannot express.
type CSF struct { type CSF struct {
// rulePrefix is prepended to a rule's comment when it is written as a // rulePrefix tags rules this library creates so they can be told apart
// full-line comment above a rule in csf.allow/csf.deny, so rules this // from foreign rules. In csf.allow/csf.deny it is prepended to the
// library creates can be told apart. csf.conf port-list rules carry no // comment written on the line above each rule; csf.conf port-list rules
// per-rule comment and so ignore it. // carry no per-rule comment and so cannot carry the tag.
rulePrefix string rulePrefix string
// ipv6Enabled mirrors csf.conf's IPV6. csf.pl's linefilter silently drops a // ipv6Enabled mirrors csf.conf's IPV6. With it off (the shipped default) csf
// csf.allow/csf.deny line (plain or advanced) that resolves to an IPv6 // enforces no IPv6 at all: csf.pl's linefilter silently drops a csf.allow/
// address whenever IPV6 is not "1" (the shipped default), so AddRule must // csf.deny line resolving to an IPv6 address, the TCP6_IN/UDP6_IN port lists
// reject that shape rather than write a line csf will never enforce. // go unread, and ip6tables is never flushed on (re)load. AddRule therefore
// ICMPv6 is unaffected: it always routes through the raw-iptables hook, // rejects every concrete-IPv6 rule (see ipv6Unavailable) rather than write one
// which runs outside csf's own IPV6-gated logic. // csf will never enforce.
ipv6Enabled bool ipv6Enabled bool
} }
@ -55,30 +52,9 @@ func NewCSF(ctx context.Context, rulePrefix string) (*CSF, error) {
csf := new(CSF) csf := new(CSF)
csf.rulePrefix = rulePrefix csf.rulePrefix = rulePrefix
// Connect to systemd dbus interface. // Confirm csf is enabled under whatever init system the host uses
conn, err := dbus.NewWithContext(ctx) // (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local).
if err != nil { if !serviceEnabled(ctx, "csf") {
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:
return nil, fmt.Errorf("the csf service is not enabled on this server") return nil, fmt.Errorf("the csf service is not enabled on this server")
} }
@ -116,7 +92,10 @@ func (f *CSF) Capabilities() Capabilities {
return Capabilities{ return Capabilities{
Output: true, Output: true,
Forward: 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 // 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 // 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 // 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 return
} }
// hook (see ruleNeedsHook), never csf's own IPV6-gated logic. Every other rule // hook returns the managed pre-hook script used to inject iptables rules for
// that reaches the native csf path (past the hook branch in addRule) with an // features csf's native config cannot express.
// implied IPv6 family is written as a v6-resolving line — whether the family
// comes from a v6 address or from a port-only deny/allow whose "any" address is
// synthesized as ::/0 (portOnlyDenyLines) — so the gate keys on the implied
// family alone.
func (f *CSF) hook() *hookScript { func (f *CSF) hook() *hookScript {
return &hookScript{ return &hookScript{
rulePrefix: f.rulePrefix, 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 // 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. // 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) { 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 return nil, err
} }
rules = append(rules, hookRules...) 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 return
} }
@ -896,15 +823,15 @@ func (f *CSF) MarshalAdvRule(r *Rule) (string, error) {
if r.Proto == ICMP && r.ICMPType == nil { if r.Proto == ICMP && r.ICMPType == nil {
return "", fmt.Errorf("a csf icmp advanced rule requires a concrete icmp type") 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 // A single advanced line carries a single protocol token, so a TCPUDP port rule
// with ProtocolAny emits a protocol-less advanced line, and csf.pl's linefilter // with an address cannot be expressed here: a protocol-less line is defaulted to
// defaults that to `-p tcp`, so the rule would silently apply to TCP only while // `-p tcp` by csf.pl's linefilter, applying the rule to TCP only while the
// the library reads it back as ProtocolAny — leaving UDP open on a deny (or // library reads it back as both transports — leaving UDP open on a deny (or
// unallowed on an accept). The port-only form has no address here (it takes the // unallowed on an accept). The address-less form is fanned into a tcp and a udp
// placeholder path, which fans ProtocolAny to tcp+udp); the address form cannot // line by the caller (portOnlyDenyLines); one line cannot fan, so reject it here
// fan within a single advanced line, so reject it rather than under-apply it. // rather than under-apply it.
if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) { if r.Proto == TCPUDP {
return "", fmt.Errorf("csf advanced rules require a concrete tcp or udp protocol for a port match with an address: %w", ErrUnsupported) 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 // 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 // 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 return strings.Join(parts, "|"), nil
} }
// advMatch reports whether a parsed advanced-rule line (the existing row) // advMatch reports whether a parsed advanced-rule line (the existing row) matches
// matches target. A ProtocolAny port rule is written as a separate tcp line and // target. A TCPUDP port rule is written as a separate tcp line and udp line, so a
// udp line, so a ProtocolAny target matches either concrete-protocol line: the // TCPUDP target matches either concrete-transport line; that coverage, like the
// parsed line's protocol is normalized to match before comparison. The family // family and direction coverage the add and remove paths need, is folded into
// coverage the add and remove paths need is folded into EqualForDedup / // EqualForDedup / EqualForRemoval.
// EqualForRemoval.
func (f *CSF) advMatch(parsed, target *Rule, remove bool) bool { 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 { 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 // 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 // address) fans out to: one per transport (tcp and udp for a TCPUDP rule) and per
// per family placeholder (0.0.0.0/0 for IPv4, ::/0 for IPv6, both for a // family placeholder (0.0.0.0/0 for IPv4, ::/0 for IPv6, both for a family-neutral
// family-neutral rule). It mirrors the emit path so the add logic can compare // rule). It mirrors the emit path so the add logic can compare against the lines
// against the lines already in the file and write only the missing ones — a single // already in the file and write only the missing ones — a single "exists" flag
// "exists" flag would skip the whole fan-out when just one family/protocol line was // would skip the whole fan-out when just one family/protocol line was already
// already present, leaving the other family/protocol open. // present, leaving the other family/protocol open.
func (f *CSF) portOnlyDenyLines(r *Rule) []string { func (f *CSF) portOnlyDenyLines(r *Rule) []string {
dir := "in" dir := "in"
if r.IsOutput() { if r.IsOutput() {
dir = "out" dir = "out"
} }
port := f.advPortValue(r.PortSpecs()) port := f.advPortValue(r.PortSpecs())
protos := []string{r.Proto.String()} var protos []string
if r.Proto == ProtocolAny { for _, sub := range expandProtocols(r) {
protos = []string{"tcp", "udp"} protos = append(protos, sub.Proto.String())
} }
var placeholders []string var placeholders []string
switch r.impliedFamily() { switch r.impliedFamily() {
@ -1013,9 +933,10 @@ func (f *CSF) portOnlyDenyLines(r *Rule) []string {
return lines return lines
} }
// EditIPList writes for a ProtocolAny port rule. Without it a ProtocolAny rule // EditIPList adds or removes a rule in a csf.allow/csf.deny list, rewriting it in
// never reads back as the rule that was written, so it is re-added on every // place. A port-only deny fans out per transport and family placeholder, so a
// reconcile and can never be matched for removal. // 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 { func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r *Rule, remove bool) error {
// Read the allow/deny IP rule list. // Read the allow/deny IP rule list.
fd, err := os.Open(filePath) 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 // 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 // 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 // or ::/0) as the address; parseAddr normalizes it back to an empty address on
// and mergeFamilies collapses the v4/v6 pair to FamilyAny on read, keeping the // read, so each family's line reads back as its own rule. The transport is named
// rule readable and removable. The transport is named explicitly (a // explicitly (a
// protocol-less line defaults to tcp in csf's linefilter), and a ProtocolAny // protocol-less line defaults to tcp in csf's linefilter), and a TCPUDP deny fans
// deny fans to both tcp and udp. // to both tcp and udp.
if len(wantDeny) > 0 { if len(wantDeny) > 0 {
writeComment := func() { writeComment := func() {
if c := combineComment(f.rulePrefix, r.Comment); c != "" { if c := combineComment(f.rulePrefix, r.Comment); c != "" {
@ -1250,8 +1171,8 @@ func (f *CSF) checkConnLimit(r *Rule) error {
// (`--icmp-type <ip>`), which csf then fails to parse and drops silently (csf.pl // (`--icmp-type <ip>`), 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 // 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 // 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 // than emit a dropped line. ICMPv6 never reaches this check: ruleNeedsHook routes it
// it to the pre-hook before addRule/RemoveRule call checkICMP. // to the pre-hook (or the IPv6 gate rejects it) before addRule/RemoveRule call checkICMP.
func (f *CSF) checkICMP(r *Rule) error { func (f *CSF) checkICMP(r *Rule) error {
if r.Proto == ICMP { if r.Proto == ICMP {
if r.Source == "" && r.Destination == "" { if r.Source == "" && r.Destination == "" {
@ -1264,15 +1185,16 @@ func (f *CSF) checkICMP(r *Rule) error {
return nil return nil
} }
// checkPortProto rejects a port match on a concrete protocol csf cannot // checkPortProto rejects a port match on a protocol csf cannot express as a port.
// express as a port. csf's port lists are TCP_IN/UDP_IN only, so a port on a // csf's port lists are TCP_IN/UDP_IN only, so a port on a concrete non-tcp/udp
// concrete non-tcp/udp protocol (e.g. sctp) would otherwise be wrongly written // protocol (e.g. sctp) would otherwise be wrongly written into BOTH lists, and a
// into BOTH the TCP and UDP lists. ProtocolAny is allowed: an address-less // port on ProtocolAny — which matches every IP protocol, not just the two csf can
// accept maps to both lists (the faithful "any" expansion) and a port-only // carry — has no faithful form at all. TCPUDP is allowed: it fans out to both lists
// reject to a protocol-less csf.deny advanced rule. // on an address-less accept, and to a tcp and a udp csf.deny advanced line
// otherwise.
func (f *CSF) checkPortProto(r *Rule) error { func (f *CSF) checkPortProto(r *Rule) error {
switch r.Proto { switch r.Proto {
case TCP, UDP, ProtocolAny: case TCP, UDP, TCPUDP:
return nil return nil
} }
if r.HasPorts() || r.HasSourcePorts() { if r.HasPorts() || r.HasSourcePorts() {
@ -1304,18 +1226,19 @@ func (f *CSF) denyAction(output bool) Action {
return dropIn 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 // 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 // can reproduce a prior snapshot's inert entries rather than be rejected by a
// gate meant to catch fresh no-op writes. // gate meant to catch fresh no-op writes.
func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6Gate bool) error { 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; // 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 // 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). // 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 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 // Features csf's native config cannot express (connection-state, per-rule
// interface, logging, rate limiting, icmpv6) are injected as iptables rules // interface, logging, rate limiting, icmpv6) are injected as iptables rules
// through the csf pre-hook. // 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 — // 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 // 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 // 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 // pre-hook (see bareProtoNeedsHook). ICMP keeps its own handling (checkICMP) and is
// the hook runs ip6tables directly, outside csf.conf's IPV6-gated logic. ICMP keeps // excluded there.
// its own handling (checkICMP) and is excluded there.
if bareProtoNeedsHook(r) { if bareProtoNeedsHook(r) {
_, err := f.hook().edit(r, false) _, err := f.hook().edit(r, false)
return err 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 { if err := f.checkSourcePort(r); err != nil {
return err return err
} }
@ -1485,6 +1419,19 @@ func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return nil 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. // Hook-injected rules (see AddRule) are removed from the managed script.
if ruleNeedsHook(r) { if ruleNeedsHook(r) {
_, err := f.hook().edit(r, true) _, 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 // 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 // rule in it carries the configured prefix; HasPrefix stays false (mirroring
// firewalld's zones). // firewalld's zones).
merged := mergeNATFamilies(rules) return rules, nil
return merged, nil
} }
// redirectAddr renders an address for a csf.redirect field, using "*" for an // 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) 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. // Backup captures the current filter and NAT rules managed by this backend.
func (f *CSF) Backup(ctx context.Context, zoneName string) (*Backup, error) { func (f *CSF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName) rules, err := f.GetRules(ctx, zoneName)
@ -1884,64 +1887,6 @@ func (f *CSF) Restore(ctx context.Context, zoneName string, backup *Backup) erro
return nil 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 // Reload restarts csf to apply config changes, retrying past csf's transient
// restart lock. // restart lock.
func (f *CSF) Reload(ctx context.Context) error { func (f *CSF) Reload(ctx context.Context) error {

View file

@ -10,43 +10,18 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// A port-only IPv6 deny carries no address (its ::/0 is synthesized on write), // A TCPUDP port-only reject must be written to csf.deny as explicit tcp and
// 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
// udp advanced lines: csf's linefilter defaults a protocol-less line to -p tcp, // 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 // so a single protocol-less line would leave udp open while the library reported
// the port blocked for all protocols. // the port blocked for all protocols.
func TestCSFProtocolAnyRejectFansOut(t *testing.T) { func TestCSFTCPUDPRejectFansOut(t *testing.T) {
ctx := context.Background() ctx := context.Background()
fw := new(CSF) fw := new(CSF)
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "csf.deny") path := filepath.Join(dir, "csf.deny")
require.NoError(t, os.WriteFile(path, nil, 0644)) 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)) require.NoError(t, fw.EditIPList(ctx, path, Reject, reject, false))
data, err := os.ReadFile(path) 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 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 // 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 ProtocolAny rule, and be fully // 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 // 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. // the pair and removal was a silent no-op.
func TestCSFProtocolAnyPortDenyRoundTrip(t *testing.T) { func TestCSFTCPUDPPortDenyRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
fw := new(CSF) fw := new(CSF)
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "csf.deny") path := filepath.Join(dir, "csf.deny")
require.NoError(t, os.WriteFile(path, nil, 0644)) 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)) require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
require.NoError(t, err) require.NoError(t, err)
@ -107,24 +82,24 @@ func TestCSFProtocolAnyPortDenyRoundTrip(t *testing.T) {
data, err = os.ReadFile(path) data, err = os.ReadFile(path)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=80|s=0.0.0.0/0"), 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"), 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) parsed, err := fw.ParseIPList(path, Drop)
require.NoError(t, err) require.NoError(t, err)
merged := fw.mergeProtocols(mergeFamilies(parsed)) require.True(t, deny.CoveredBy(parsed), "the tcp+udp deny lines must cover the TCPUDP rule")
require.Len(t, merged, 1, "the tcp+udp deny lines must merge to one ProtocolAny rule") for _, g := range parsed {
require.Equal(t, ProtocolAny, merged[0].Proto) require.True(t, deny.Covers(g), "a fanned line must not widen the rule: %+v", g)
require.True(t, merged[0].EqualBase(deny, true), "the merged rule must equal the ProtocolAny deny that was added") }
// A single RemoveRule must drop every fanned line. // A single RemoveRule must drop every fanned line.
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true)) require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true))
data, err = os.ReadFile(path) data, err = os.ReadFile(path)
require.NoError(t, err) require.NoError(t, err)
require.NotContains(t, string(data), "d=80", 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 // 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"), 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") "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") 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)) 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)) require.NoError(t, fw.EditIPList(ctx, path2, Drop, anyDeny, false))
data2, err := os.ReadFile(path2) data2, err := os.ReadFile(path2)
require.NoError(t, err) 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") "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 // 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 // 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 // under-apply it (the port-only form fans out instead, but that path has no
// address to place). // address to place).
func TestCSFAdvRuleProtocolAnyWithAddressRejected(t *testing.T) { func TestCSFAdvRuleTCPUDPWithAddressRejected(t *testing.T) {
fw := new(CSF) 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, 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. // 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}) _, 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") 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 // 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. // found and removed by the same Drop rule rather than leaking.
func TestCSFDropRuleRemovable(t *testing.T) { 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 // 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 // wrongly be written into both, so it must error. ProtocolAny carries a port on no
// to both lists as the faithful "any" expansion). // protocol csf can express — it matches every IP protocol — so it errors too. Only
// TCPUDP maps to both lists.
func TestCSFPortProtoGuard(t *testing.T) { func TestCSFPortProtoGuard(t *testing.T) {
fw := new(CSF) fw := new(CSF)
require.Error(t, fw.checkPortProto(&Rule{Proto: SCTP, Port: 80, Action: Accept}), "sctp port must be rejected") 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: TCP, Port: 80, Action: Accept}))
require.NoError(t, fw.checkPortProto(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), 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 // 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 // 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 // (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 // 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 // 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 // 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 // address (so csf enforces it) and must still round-trip and remove: parseAddr
// normalizes the "any" network back to an empty address, and mergeFamilies // normalizes the "any" network back to an empty address, and a family-neutral rule
// collapses the v4/v6 pair a family-neutral rule writes. // writes one line per family, which cover it between them.
func TestCSFPortOnlyRejectRoundTrip(t *testing.T) { func TestCSFPortOnlyRejectRoundTrip(t *testing.T) {
fw := new(CSF) fw := new(CSF)
ctx := context.Background() 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"), 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) "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) got, err := fw.ParseIPList(deny, Reject)
require.NoError(t, err) require.NoError(t, err)
got = mergeFamilies(got) require.Len(t, got, wantRows, "port-only reject (%s) must round-trip to %d row(s)", rule.Family, wantRows)
require.Len(t, got, 1, "port-only reject (%s) must round-trip to one rule", rule.Family) require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule: %+v", got)
require.True(t, rule.Equal(got[0], false), "read-back rule must equal the written one: %+v", got[0]) 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). // It must also be removable (matched back on delete).
require.NoError(t, fw.EditIPList(ctx, deny, Reject, rule, true)) 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") require.NotContains(t, string(data), "d=22", "a FamilyAny removal must clear both placeholder lines")
} }
// TestCSFMergeProtocolsRespectsFamily guards mergeProtocols against collapsing a // CSF expresses IPv4 and IPv6 opens through separate config keys (TCP_IN vs
// tcp/udp pair that differ in IP family. CSF expresses IPv4 and IPv6 opens through // TCP6_IN), so a `TCP_IN="53"` + `UDP6_IN="53"` config produces a tcp/IPv4 rule and
// separate config keys (TCP_IN vs TCP6_IN), so a `TCP_IN="53"` + `UDP6_IN="53"` // a udp/IPv6 rule. Those cover different families, and neither a TCPUDP/IPv4 rule nor
// config produces a tcp/IPv4 rule and a udp/IPv6 rule. Those cover different // its IPv6 twin may be reported as present against them — treating the pair as one
// families and must NOT merge into one ProtocolAny rule — doing so drops the IPv6 // both-transports rule drops a family's coverage and makes Sync churn forever.
// coverage from the read-back and makes Sync churn forever. func TestCSFCrossFamilyPairCoversNeitherTransportPair(t *testing.T) {
func TestCSFMergeProtocolsRespectsFamily(t *testing.T) { stored := []*Rule{
fw := new(CSF) {Family: IPv4, Proto: TCP, Port: 53, Action: Accept},
// tcp/IPv4 and udp/IPv6, both port 53 inbound — same in every field but proto {Family: IPv6, Proto: UDP, Port: 53, Action: Accept},
// and family. }
tcpV4 := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}
udpV6 := &Rule{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 { // Each stored rule still covers exactly its own cell.
t.Fatalf("a cross-family tcp/udp pair must not merge: got %d rules %+v, want 2", len(out), out) 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))
// 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)
}
} }
// TestCSFMergeProtocolsSameFamily confirms the intended merge still happens for a // A same-family tcp/udp pair — the fanned-out form csf writes for one TCPUDP port
// same-family tcp/udp pair (the fanned-out form of one ProtocolAny port rule). // rule — does cover that rule.
func TestCSFMergeProtocolsSameFamily(t *testing.T) { func TestCSFSameFamilyPairCoversTCPUDP(t *testing.T) {
fw := new(CSF) stored := []*Rule{
tcp := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept} {Family: IPv4, Proto: TCP, Port: 53, Action: Accept},
udp := &Rule{Family: IPv4, Proto: UDP, Port: 53, Action: Accept} {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)
} }
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 // GetRules reports both the library's own rules and foreign ones, each tagged

View file

@ -144,6 +144,14 @@ const (
GRE GRE
ESP ESP
AH 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. // String returns the canonical lower-case name of the protocol.
@ -153,6 +161,8 @@ func (t Protocol) String() string {
return "udp" return "udp"
case TCP: case TCP:
return "tcp" return "tcp"
case TCPUDP:
return "tcpudp"
case ICMP: case ICMP:
return "icmp" return "icmp"
case ICMPv6: case ICMPv6:
@ -174,10 +184,26 @@ func (t Protocol) IsICMP() bool {
return t == ICMP || t == ICMPv6 return t == ICMP || t == ICMPv6
} }
// HasPorts reports whether the protocol carries layer-4 ports (TCP, UDP or // HasPorts reports whether the protocol carries layer-4 ports (TCP, UDP, SCTP or
// SCTP). A port match is only meaningful and only valid for these protocols. // the merged TCPUDP). A port match is only meaningful and only valid for these
// protocols.
func (t Protocol) HasPorts() bool { 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 // 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 return UDP
case strings.EqualFold("tcp", proto): case strings.EqualFold("tcp", proto):
return TCP return TCP
case strings.EqualFold("tcpudp", proto):
return TCPUDP
case strings.EqualFold("icmp", proto): case strings.EqualFold("icmp", proto):
return ICMP return ICMP
case strings.EqualFold("icmpv6", proto), case strings.EqualFold("icmpv6", proto),
@ -790,10 +818,10 @@ type Rule struct {
// (Capabilities().RuleOrdering). It mirrors the position argument of InsertRule // (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. // 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 // 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 // ordered set (foreign rules in a container backend's read). A rule the backend
// across IPv4 and IPv6 it reflects the IPv4 chain. Like HasPrefix and // stores as one row per IP family is reported once per row, each with its own
// Packets/Bytes it is derived on read, ignored when adding a rule, and not part // Number. Like HasPrefix and Packets/Bytes it is derived on read, ignored when
// of rule identity. // adding a rule, and not part of rule identity.
Number int Number int
// table records the backend container a container backend (an nft table, a pf // 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 // 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 // directionFromOutput maps an input/output boolean to a Direction. Backends whose
// native config distinguishes only inbound from outbound use it when decoding a // 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 // 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 // their native config, so it is never decoded here). It only ever yields a concrete
// concrete DirInput or DirOutput; DirAny is synthesized later by the read-side // DirInput or DirOutput; a backend reports DirAny only for an entry whose native
// direction merge (see mergeDirections), never by a single-row decode. // form covers both directions, which it decodes itself.
func directionFromOutput(output bool) Direction { func directionFromOutput(output bool) Direction {
if output { if output {
return DirOutput return DirOutput
@ -929,9 +957,10 @@ func (r *Rule) impliedFamily() Family {
// the in/out interface all swap sides. Everything protocol- or policy-bound (Proto, // the in/out interface all swap sides. Everything protocol- or policy-bound (Proto,
// ICMPType, State, Action, Log, rate/conn limits, Family, Priority, Comment, // ICMPType, State, Action, Log, rate/conn limits, Family, Priority, Comment,
// counters, Number, table) is direction-independent and is left untouched. It backs // 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 // the DirAny write-side fan-out (expandDirections), the direction split on removal,
// (mergeDirections). The Ports/SourcePorts slice headers are swapped, not their // and the inbound-frame comparison rule identity uses. The Ports/SourcePorts slice
// elements; callers do not mutate them, matching splitDualRow's shallow-copy style. // headers are swapped, not their elements; callers do not mutate them, matching
// splitDualRow's shallow-copy style.
func (r *Rule) directionSwapped() *Rule { func (r *Rule) directionSwapped() *Rule {
s := *r s := *r
s.Source, s.Destination = r.Destination, r.Source 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 // with direction excluded from the field compare since coversDirection already
// gated it. // gated it.
func (r *Rule) EqualForDedup(o *Rule, outputHonored bool) bool { func (r *Rule) EqualForDedup(o *Rule, outputHonored bool) bool {
fe, fr := r.impliedFamily(), o.impliedFamily() return r.covers(o, outputHonored)
if !(fe == FamilyAny || fe == fr) { }
// 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 return false
} }
if !coversDirection(r.Direction, o.Direction, outputHonored) { if !coversDirection(r.Direction, o.Direction, outputHonored) {
return false 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 // 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 // on when the caller targets o: the same base rule, and o's family, transport and
// may touch the row's. It is the family- and direction-aware remove/move guard for // direction each touch the row's. It is the overlap relation a removal walks the
// the backends whose GetRules merges a v4/v6 pair into one FamilyAny rule or an // stored rows with, so a target removes every row it covers (rule 3) and also
// in/out pair into one DirAny rule — a FamilyAny/DirAny target matches every row on // matches a row that covers more than the target, which the backend then deletes and
// that axis, a FamilyAny/DirAny row matches any target, and otherwise the values // re-adds minus the targeted cell (splitMergedRow, rule 4). A FamilyAny/TCPUDP/DirAny
// must match so acting on one twin never disturbs the other. Family and direction // target matches every row on that axis, such a row matches any target, and otherwise
// are checked first so a non-matching row skips the field compare, which runs in // the values must match so acting on one twin never disturbs the other. Family and
// the inbound frame (canonicalMatch) with direction excluded. // 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 { func (r *Rule) EqualForRemoval(o *Rule, outputHonored bool) bool {
ft, fr := o.impliedFamily(), r.impliedFamily() ft, fr := o.impliedFamily(), r.impliedFamily()
if !(ft == FamilyAny || fr == FamilyAny || ft == fr) { 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) { if !coversDirectionRemoval(r.Direction, o.Direction, outputHonored) {
return false 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 // oppositeFamily returns the other concrete IP family: IPv4 for IPv6 and vice
@ -1207,21 +1280,6 @@ func splitDualRow(matched, target *Rule) *Rule {
return &opp 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 // expandDirections returns the concrete-direction rows a rule materializes into on
// write: itself for a concrete direction, or an inbound (DirInput) row plus its // 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 // 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} 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 // 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 // direction of a genuine DirAny row — a single stored object covering both the
// input and output directions — to satisfy a concrete-direction removal: the // input and output directions — to satisfy a concrete-direction removal: the
@ -1323,193 +1560,30 @@ func (r *Rule) checkICMPType() error {
return nil return nil
} }
// familyMergePairs computes the cross-family pairing shared by every merge // CheckExpandedProtocol reports a TCPUDP rule reaching a row-level marshaller.
// helper below (filter and NAT, both the collapse and the anchor-index forms), so // TCPUDP is a merged, logical protocol: a backend with no both-transports form fans
// the pairing rule lives in exactly one place. For n rows it scans in order and, // it into a tcp row and a udp row with expandProtocols before marshalling, so a
// for each concrete-family row not already absorbed, finds the first later // TCPUDP rule arriving here means that fan-out was skipped. Backends whose native
// opposite-family row whose base is equal and marks it absorbed — each anchor // syntax does carry both transports in one row (nftables' `meta l4proto { tcp, udp }`)
// absorbs at most one twin. family reports a row's IP family; equalBase reports // do not call it.
// whether rows i and j are equal ignoring family. It returns, per index, whether func (r *Rule) CheckExpandedProtocol() error {
// that row was absorbed into an earlier anchor, and per anchor the index of the if r.Proto == TCPUDP {
// twin it absorbed (-1 if none). It mutates nothing. return fmt.Errorf("the tcpudp protocol matches two transports and must be expanded to a tcp and a udp rule")
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
} }
for i := 0; i < n; i++ { return nil
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
} }
// mergeFamilies collapses pairs of otherwise-identical rules that differ only in // logicalInsertIndex maps a 1-based logical position (a rule's Number, as GetRules
// IP family (one IPv4, one IPv6) into a single FamilyAny rule. Rules that are // reports it) to the 0-based physical index to insert before, given the physical
// already FamilyAny, or two rules of the same family, are left untouched so that // index of each logical rule and the physical row count. A position past the last
// a duplicate within a single family is never mistaken for cross-family // logical rule appends (returns physicalLen). It exists because a backend's physical
// coverage. The surviving anchor of each merged pair is flipped to FamilyAny. // list may hold rows GetRules does not report as their own rule — an unmodeled
func mergeFamilies(rules []*Rule) []*Rule { // foreign line pf keeps as an opaque row, a route tuple ufw numbers but this backend
absorbed, twin := familyMergePairs(len(rules), // cannot model, the LOG line iptables folds into the action line that follows it —
func(i int) Family { return rules[i].Family }, // so a logical position counted over the reported rules lands at the wrong physical
func(i, j int) bool { return rules[i].EqualBase(rules[j], true) }) // row unless it is mapped through the anchors. When every physical row is its own
out := make([]*Rule, 0, len(rules)) // logical rule this reduces to position-1, the plain physical index.
for i, r := range rules { func logicalInsertIndex(anchors []int, physicalLen, position int) int {
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 {
if position < 1 { if position < 1 {
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 // forward chains are ordered independently (iptables, nftables) number rules
// this way so a rule's Number matches the InsertRule/MoveRule position for its // 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. 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 // chain, as a FamilyAny rule's Number reflects the IPv4 chain. It is derived on
// mergeDirections so the surviving pure-output rows keep the physical output // read and, like HasPrefix, ignored on add and not part of rule identity.
// position of their still-present twin. It is derived on read and, like HasPrefix,
// ignored on add and not part of rule identity.
func numberByDirection(rules []*Rule) { func numberByDirection(rules []*Rule) {
var in, out, fwd int var in, out, fwd int
for _, r := range rules { for _, r := range rules {
@ -1718,6 +1790,14 @@ func (r *NATRule) validate() error {
default: default:
return fmt.Errorf("invalid nat kind") 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) { if portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) {
return fmt.Errorf("a port requires a tcp or udp protocol") 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 // EqualForDedup is the NAT-rule add guard mirroring Rule.EqualForDedup: the same
// base translation, and the receiver's family covers o's. // base translation, and the receiver's family covers o's.
func (r *NATRule) EqualForDedup(o *NATRule) bool { func (r *NATRule) EqualForDedup(o *NATRule) bool {
fe, fr := r.impliedFamily(), o.impliedFamily() return r.Covers(o)
return (fe == FamilyAny || fe == fr) && r.EqualBase(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: // 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) 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 — // numberNATByChain assigns each NAT rule a 1-based Number within its nat chain —
// prerouting for destination NAT, postrouting for source NAT — matching the // prerouting for destination NAT, postrouting for source NAT — matching the
// InsertNATRule position on chain-ordered backends (iptables, nftables). // 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 is the routing (forward) direction, where a backend models it.
DirForward DirForward
// DirAny applies to both the input and output directions. It is the direction // 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 // analog of FamilyAny: a backend that can store a bidirectional rule as one
// role-swapped output twin into one DirAny rule, and a write-side fan-out // object reads it back as DirAny, while one that cannot fans it into a concrete
// expands a DirAny rule back into a concrete input row plus a swapped output // input row plus a role-swapped output row on write (expandDirections). It never
// row. It never covers DirForward (a routed rule has no input/output twin) and // covers DirForward (a routed rule has no input/output twin) and must be declared
// must be declared last so DirInput stays the zero value. // last so DirInput stays the zero value.
DirAny DirAny
) )
@ -2003,6 +2091,22 @@ type Capabilities struct {
Comments bool 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. // Manager is the standard firewall manager interface.
// //
// Every method that performs I/O (shelling out, D-Bus, or the Windows API) // 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 // 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 // the backend reports that desired does not cover and adds desired rules that are
// yet present, leaving rules already in place untouched. It reconciles the // 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 // 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 // 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 // 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 // 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 // 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 // applied atomically. Sync reports how many rules were added and removed.
// identity is compared with Rule.Equal honoring //
// the backend's Output capability, so the Comment, HasPrefix and Packets/Bytes // The diff is a coverage relation, not rule-for-rule equality, because GetRules
// fields do not affect the diff. // 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) { func Sync(ctx context.Context, mgr Manager, zoneName string, desired []*Rule) (added, removed int, err error) {
existing, err := mgr.GetRules(ctx, zoneName) existing, err := mgr.GetRules(ctx, zoneName)
if err != nil { if err != nil {
@ -2184,63 +2297,34 @@ func Sync(ctx context.Context, mgr Manager, zoneName string, desired []*Rule) (a
} }
outputHonored := mgr.Capabilities().Output outputHonored := mgr.Capabilities().Output
// Canonicalize desired the same way GetRules canonicalizes existing: collapse an // Remove any existing rule desired does not fully cover. Sync reconciles the
// otherwise-identical IPv4/IPv6 pair into one FamilyAny rule, then collapse an // actual firewall state toward desired, so any rule the backend reports and can
// input rule and its role-swapped output twin into one DirAny rule. GetRules // act on is fair game; backends whose mutations are scoped to a private
// merges such pairs on read, so a caller that lists the families or directions // table/anchor simply no-op on rules outside it. A rule whose cells are spread
// separately would never match the single merged rule and Sync would // across several desired rules is still fully wanted and is kept.
// remove-and-re-add it on every run (a transient gap). Copy first — the merges kept := make([]*Rule, 0, len(existing))
// 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.
for _, e := range existing { for _, e := range existing {
keep := false if e.coveredBy(desired, outputHonored) {
for _, d := range desired { kept = append(kept, e)
if e.Equal(d, outputHonored) { continue
keep = true
break
}
} }
if !keep { if err := mgr.RemoveRule(ctx, zoneName, e); err != nil {
if err := mgr.RemoveRule(ctx, zoneName, e); err != nil { return added, removed, err
return added, removed, err
}
removed++
} }
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 var toAdd []*Rule
for _, d := range desired { for _, d := range desired {
present := false if d.coveredBy(kept, outputHonored) || d.coveredBy(toAdd, outputHonored) {
for _, e := range existing { continue
// 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)
} }
toAdd = append(toAdd, d)
} }
if err := AddRules(ctx, mgr, zoneName, toAdd); err != nil { if err := AddRules(ctx, mgr, zoneName, toAdd); err != nil {
return added, removed, err return added, removed, err

View file

@ -112,34 +112,48 @@ func (m *syncDirRecorder) RemoveRule(_ context.Context, _ string, r *Rule) error
return nil return nil
} }
// Sync must not churn a DirAny rule: whether the desired set lists it as one DirAny // Sync must not churn a DirAny rule, whichever representation the backend chose: one
// rule or as the two separate directions, mergeDirectionsCopy canonicalizes it to // that stores it as a single bidirectional object reports one DirAny rule, while one
// match the merged existing rule, so nothing is added or removed. // 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) { func TestSyncDirAnyIdempotent(t *testing.T) {
existing := func() []*Rule { dirAny := func() []*Rule {
return []*Rule{{Direction: DirAny, Source: "1.2.3.4", Action: Accept}} 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. // The backend stored it as one DirAny row; desired names it the same way.
m := &syncDirRecorder{existing: existing()} m := &syncDirRecorder{existing: dirAny()}
added, removed, err := Sync(context.Background(), m, "", []*Rule{{Direction: DirAny, Source: "1.2.3.4", Action: Accept}}) added, removed, err := Sync(context.Background(), m, "", dirAny())
require.NoError(t, err) require.NoError(t, err)
require.Zero(t, added) require.Zero(t, added)
require.Zero(t, removed) require.Zero(t, removed)
require.Empty(t, m.added) require.Empty(t, m.added)
require.Empty(t, m.removed) require.Empty(t, m.removed)
// Desired lists the two directions separately; they collapse to DirAny and match. // The backend fanned it out into two concrete rows; desired still names one DirAny
m2 := &syncDirRecorder{existing: existing()} // rule, whose cells those two rows cover between them.
added, removed, err = Sync(context.Background(), m2, "", []*Rule{ m2 := &syncDirRecorder{existing: fannedOut()}
{Direction: DirInput, Source: "1.2.3.4", Action: Accept}, added, removed, err = Sync(context.Background(), m2, "", dirAny())
{Direction: DirOutput, Destination: "1.2.3.4", Action: Accept},
})
require.NoError(t, err) 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.Zero(t, removed)
require.Empty(t, m2.added) require.Empty(t, m2.added)
require.Empty(t, m2.removed) 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) { 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}) 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, // A backend whose physical list holds rows GetRules does not report as their own rule
// ufw's numbered list) must translate a caller's merged position into the physical // — pf's opaque foreign lines, ufw's unmodeled route tuples, iptables' LOG line folded
// index of that logical rule's anchor row. The naive position-1 lands too early — // into the action that follows it — must translate a caller's logical position into
// inside a merged IPv4/IPv6 pair — once such a pair precedes the target. // the physical index of that rule's row. The naive position-1 lands too early once
func TestMergedInsertIndex(t *testing.T) { // such a row precedes the target.
// Raw (unmerged) list: an ssh IPv4/IPv6 pair that mergeFamilies collapses into func TestLogicalInsertIndex(t *testing.T) {
// one logical rule, followed by an IPv4-only https rule. // Five physical rows where rows 1 and 3 are unmodeled, so the three reported rules
raw := []*Rule{ // live at physical indices 0, 2 and 4.
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, anchors := []int{0, 2, 4}
{Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, const physicalLen = 5
{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")
// Insert before the https rule, which GetRules numbers as merged position 2. require.Equal(t, 0, logicalInsertIndex(anchors, physicalLen, 1))
// The physical index must be 2 (before the https row), not the naive 1 (which require.Equal(t, 2, logicalInsertIndex(anchors, physicalLen, 2))
// would split the ssh IPv4/IPv6 pair). require.NotEqual(t, 2-1, logicalInsertIndex(anchors, physicalLen, 2),
require.Equal(t, 2, mergedInsertIndex(anchors, len(raw), 2)) "a logical position must not be used as a raw index")
require.NotEqual(t, 2-1, mergedInsertIndex(anchors, len(raw), 2), require.Equal(t, 4, logicalInsertIndex(anchors, physicalLen, 3))
"the merged position must not be used as a raw index")
// Position 1 prepends (physical index 0); a position past the end appends. // A position past the last logical rule appends; a non-positive position prepends.
require.Equal(t, 0, mergedInsertIndex(anchors, len(raw), 1)) require.Equal(t, physicalLen, logicalInsertIndex(anchors, physicalLen, 4))
require.Equal(t, len(raw), mergedInsertIndex(anchors, len(raw), 3)) require.Equal(t, physicalLen, logicalInsertIndex(anchors, physicalLen, 99))
require.Equal(t, len(raw), mergedInsertIndex(anchors, len(raw), 99)) require.Equal(t, 0, logicalInsertIndex(anchors, physicalLen, 0), "a non-positive position prepends")
require.Equal(t, 0, mergedInsertIndex(anchors, len(raw), 0), "a non-positive position prepends")
// With no merged pair, every row is its own anchor and the mapping is identity. // With every row modeled, the mapping is the identity.
flat := []*Rule{ flat := []int{0, 1, 2}
{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)
for pos := 1; pos <= len(flat); pos++ { 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)) require.True(t, in.EqualBase(fwd, false))
} }
// ErrUnsupportedForward is a member of the ErrUnsupported family so a caller can // A backend that cannot store one family-agnostic row reports a FamilyAny rule as an
// tell a genuinely unexpressible forward rule from a real failure. // IPv4 row plus an IPv6 row. The pair must cover the rule the caller authored, and a
func TestErrUnsupportedForward(t *testing.T) { // same-family duplicate must not — a duplicate within one family is not cross-family
require.ErrorIs(t, ErrUnsupportedForward, ErrUnsupported) // coverage, and reporting it as such would leave the other family unprotected.
require.ErrorIs(t, unsupportedForward("test"), ErrUnsupportedForward) func TestFamilyCoverage(t *testing.T) {
require.ErrorIs(t, unsupportedForward("test"), ErrUnsupported) want := &Rule{Family: FamilyAny, Port: 80, Proto: TCP, Action: Accept}
}
func TestMergeFamilies(t *testing.T) { require.True(t, want.CoveredBy([]*Rule{
// A genuine IPv4/IPv6 pair that is otherwise identical collapses to one
// FamilyAny rule.
merged := mergeFamilies([]*Rule{
{Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 80, Proto: TCP, Action: Accept},
{Family: IPv6, Port: 80, Proto: TCP, Action: Accept}, {Family: IPv6, Port: 80, Proto: TCP, Action: Accept},
}) }), "a v4/v6 pair covers the FamilyAny rule it materializes")
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")
// Two identical same-family rules must NOT be collapsed into FamilyAny; a require.False(t, want.CoveredBy([]*Rule{
// duplicate within a single family is not cross-family coverage.
merged = mergeFamilies([]*Rule{
{Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 80, Proto: TCP, Action: Accept},
{Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 80, Proto: TCP, Action: Accept},
}) }), "a same-family duplicate must not be mistaken for cross-family coverage")
for _, r := range merged {
require.NotEqual(t, FamilyAny, r.Family, // A single family-agnostic row covers both halves on its own.
"same-family duplicate was wrongly merged into FamilyAny: %+v", merged) 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 // 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)) require.True(t, coversDirectionRemoval(DirForward, DirForward, true))
} }
// mergeDirections collapses an input rule and its role-swapped output twin into one // A backend that cannot store a bidirectional rule reports a DirAny rule as an input
// DirAny rule, honoring family and never merging a forward rule. // row plus its role-swapped output row. Coverage honors that swap, honors family, and
func TestMergeDirections(t *testing.T) { // never accepts a forward rule as one half of the pair.
// A csf-style host allow: inbound Source=X + outbound Destination=X collapse to func TestDirectionCoverage(t *testing.T) {
// one DirAny rule in the inbound frame (Source=X). // A csf-style host allow: inbound Source=X plus outbound Destination=X cover the
merged := mergeDirections([]*Rule{ // 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: DirInput, Family: IPv4, Source: "1.2.3.4", Action: Accept},
{Direction: DirOutput, Family: IPv4, Destination: "1.2.3.4", Action: Accept}, {Direction: DirOutput, Family: IPv4, Destination: "1.2.3.4", Action: Accept},
}) }), "in+out host allow covers the DirAny rule")
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)
// A ported service both ways: dport 22 inbound pairs with sport 22 outbound. // 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: DirInput, Proto: TCP, Port: 22, Action: Accept},
{Direction: DirOutput, Proto: TCP, SourcePort: 22, Action: Accept}, {Direction: DirOutput, Proto: TCP, SourcePort: 22, Action: Accept},
}) }), "dport-in + sport-out covers the DirAny rule")
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)
// A forward rule must never merge, even if a swap-equal partner exists. // A forward rule is not the outbound half of anything: it has no in/out twin.
merged = mergeDirections([]*Rule{ require.False(t, svc.CoveredBy([]*Rule{
{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept}, {Direction: DirInput, Proto: TCP, Port: 22, Action: Accept},
{Direction: DirOutput, Proto: TCP, SourcePort: 22, Action: Accept}, {Direction: DirForward, Proto: TCP, SourcePort: 22, Action: Accept},
}) }), "a forward rule never covers the outbound half of a DirAny rule")
require.Len(t, merged, 2, "forward rules never participate in the direction merge")
// A cross-family pair must not merge across direction (v4-in with v6-out). // An opposite-family outbound row leaves the IPv4 outbound cell uncovered.
merged = mergeDirections([]*Rule{ 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: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept}, {Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept},
}) }), "an opposite-family outbound row must not cover the IPv4 outbound cell")
require.Len(t, merged, 2, "an opposite-family in/out pair must not merge")
} }
// A rule present as {v4-in, v6-in, v4-out, v6-out} collapses, after mergeFamilies // A rule spanning family and direction has four cells, and a backend that can store
// then mergeDirections, to a single FamilyAny + DirAny rule. // neither axis reports it as {v4-in, v6-in, v4-out, v6-out}. All four together cover
func TestMergeFamiliesThenDirectionsFourRows(t *testing.T) { // it; any three do not.
func TestFamilyAndDirectionCoverageFourRows(t *testing.T) {
want := &Rule{Direction: DirAny, Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept}
rows := []*Rule{ rows := []*Rule{
{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, {Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Direction: DirInput, Family: IPv6, 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: IPv4, Proto: TCP, SourcePort: 22, Action: Accept},
{Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept}, {Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept},
} }
rows = mergeFamilies(rows) require.True(t, want.CoveredBy(rows), "the four cells cover the rule")
numberByDirection(rows) require.False(t, want.CoveredBy(rows[:3]), "dropping the v6 outbound cell breaks coverage")
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)
} }
// A rule that is symmetric under the swap (Source==Destination, no ports) still // A rule symmetric under the direction swap (Source==Destination, no ports) is
// merges its in/out pair, and the surviving DirAny rule is unchanged by the swap. // covered by its in/out pair even though the swap is a no-op on it.
func TestMergeDirectionsSymmetric(t *testing.T) { func TestDirectionCoverageSymmetric(t *testing.T) {
merged := mergeDirections([]*Rule{ 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: 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}, {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 // 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)) require.Error(t, json.Unmarshal([]byte(`"bogus"`), &p))
} }
// TestMergedNATInsertIndexMasqueradePair verifies the merged-position mapping an // A FamilyAny masquerade rule a backend stored as an IPv4 row plus an IPv6 row is
// ordered NAT backend (pf, nft) uses for InsertNATRule: GetNATRules merges an // still fully present, so a caller must not be told to add it again. Coverage decides
// IPv4/IPv6 masquerade pair into one logical rule, so a caller-supplied position // this cell by cell, since no single row Covers the FamilyAny rule.
// must map back to the physical row before which to insert. Without the mapping a func TestNATFamilyCoverage(t *testing.T) {
// plain position-1 would splice a new rule into the middle of the merged pair. masq := &NATRule{Kind: Masquerade, Interface: "em0", Family: FamilyAny}
func TestMergedNATInsertIndexMasqueradePair(t *testing.T) { require.True(t, masq.CoveredBy([]*NATRule{
// Physical anchor order: masquerade IPv4, its IPv6 twin, then a DNAT rule. {Kind: Masquerade, Interface: "em0", Family: IPv4},
masqV4 := &NATRule{Kind: Masquerade, Interface: "em0", Family: IPv4} {Kind: Masquerade, Interface: "em0", Family: IPv6},
masqV6 := &NATRule{Kind: Masquerade, Interface: "em0", Family: IPv6} }), "the v4/v6 pair covers the family-agnostic masquerade rule")
dnat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", Family: IPv4}
physical := []*NATRule{masqV4, masqV6, dnat}
// GetNATRules collapses the masquerade pair, so it reports masq=Number 1, require.False(t, masq.CoveredBy([]*NATRule{
// dnat=Number 2. Inserting at merged position 2 means "before the DNAT rule". {Kind: Masquerade, Interface: "em0", Family: IPv4},
anchors := mergedNATFamilyAnchors(physical) }), "the IPv4 row alone leaves the IPv6 cell uncovered")
require.Equal(t, []int{0, 2}, anchors, "the masquerade twin at index 1 is absorbed")
idx := mergedInsertIndex(anchors, len(physical), 2) // A single family-agnostic row covers both halves on its own.
require.Equal(t, 2, idx, "position 2 must map before the DNAT row (physical index 2), not split the masq pair") require.True(t, masq.CoveredBy([]*NATRule{
{Kind: Masquerade, Interface: "em0", Family: FamilyAny},
// 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))
} }
// TestEqualForDedupFamily locks the add-time dedup family-cover relation: an // 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 return nil
} }
// Sync must not churn when GetRules has merged an IPv4/IPv6 pair into one // Sync must not churn on the family axis whichever side is the merged one: a stored
// FamilyAny rule but the caller lists the two families separately: the merged // family-agnostic row covers a caller listing the two families separately, and a pair
// existing rule already covers both desired twins, so nothing is added or removed. // of stored family-pinned rows covers a caller naming one FamilyAny rule. The second
func TestSyncMergedFamilyNoChurn(t *testing.T) { // case is the one every fan-out backend hits against its own output.
merged := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop} func TestSyncFamilyNoChurn(t *testing.T) {
m := &syncStateMock{existing: []*Rule{merged}} // 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{ desired := []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Action: Drop}, {Family: IPv4, Proto: TCP, Port: 22, Action: Drop},
{Family: IPv6, Proto: TCP, Port: 22, Action: Drop}, {Family: IPv6, Proto: TCP, Port: 22, Action: Drop},
} }
added, removed, err := Sync(context.Background(), m, "", desired) added, removed, err := Sync(context.Background(), m, "", desired)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 0, removed, "the merged existing rule covers both twins; nothing removed") require.Equal(t, 0, removed, "the family-agnostic row covers both desired twins; nothing removed")
require.Equal(t, 0, added, "the merged existing rule covers both twins; nothing added") require.Equal(t, 0, added, "the family-agnostic row covers both desired twins; nothing added")
require.Empty(t, m.removed) require.Empty(t, m.removed)
require.Empty(t, m.added) require.Empty(t, m.added)
require.NotNil(t, desired[0], "Sync must not mutate the caller's desired slice entries") 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") 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 // 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") 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 // The matcher pf and nft RemoveRule use — EqualForRemoval — must match a FamilyAny
// FamilyAny rule against both concrete physical rows, and a concrete-family // target against both concrete physical rows, so removing it clears every row it
// target must match only its own row. // covers, while a concrete-family target matches only its own row.
func TestMergedFamilyMatcherFindsBothRows(t *testing.T) { func TestFamilyAnyRemovalMatchesBothRows(t *testing.T) {
v4 := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept} v4 := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 22, Action: Accept} v6 := &Rule{Family: IPv6, Proto: TCP, Port: 22, Action: Accept}
merged := mergeFamilies([]*Rule{ any := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept}
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, // A FamilyAny target matches both rows.
}) require.True(t, v4.EqualForRemoval(any, true))
require.Len(t, merged, 1) require.True(t, v6.EqualForRemoval(any, true))
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))
// A concrete IPv4 target matches only the IPv4 row. // A concrete IPv4 target matches only the IPv4 row.
require.True(t, v4.EqualForRemoval(v4, true)) require.True(t, v4.EqualForRemoval(v4, true))
require.False(t, v6.EqualForRemoval(v4, true)) require.False(t, v6.EqualForRemoval(v4, true))
@ -1146,20 +1157,27 @@ func TestParseRateUnit(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} }
func TestRateLimitString(t *testing.T) { func TestNATRuleEqualAndCovers(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) {
a := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} 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} 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.True(t, a.EqualBase(b), "same match, different family")
require.False(t, a.Equal(b), "Equal must honor family") require.False(t, a.Equal(b), "Equal must honor family")
merged := mergeNATFamilies([]*NATRule{a, b}) // A DNAT to an IPv4 address is an IPv4 rule whatever its Family field says, so
require.Len(t, merged, 1, "an ipv4/ipv6 pair should merge") // coverage is decided on the implied family. Use a redirect, which names no
require.Equal(t, FamilyAny, merged[0].Family) // 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) { func TestRuleLogLimitIdentity(t *testing.T) {
@ -1206,65 +1224,34 @@ func TestCommentNotPartOfIdentity(t *testing.T) {
require.True(t, a.EqualBase(c, true)) require.True(t, a.EqualBase(c, true))
} }
// Direction/SetType string rendering is stable for the rule encoders. // GetRules reports one rule per physical row, so a rule's Number is its own row's
func TestDirectionAndSetType(t *testing.T) { // rank within its chain. An IPv4 row and its IPv6 twin each get their own Number —
require.Equal(t, "input", DirInput.String()) // nothing is collapsed — which is what keeps InsertRule/MoveRule positions aligned
require.Equal(t, "output", DirOutput.String()) // with the physical chain a backend edits.
require.Equal(t, "forward", DirForward.String()) func TestNumberByDirectionCountsEveryRow(t *testing.T) {
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) {
mk := func(fam Family, port uint16) *Rule { mk := func(fam Family, port uint16) *Rule {
return &Rule{Family: fam, Proto: TCP, Port: port, Action: Accept} 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)} phys := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(FamilyAny, 80)}
anchors := mergedFamilyAnchors(phys) numberByDirection(phys)
require.Equal(t, []int{0, 2}, anchors, require.Equal(t, []int{1, 2, 3}, []int{phys[0].Number, phys[1].Number, phys[2].Number},
"the merged pair anchors at physical index 0; C stays at physical index 2") "the v4/v6 twins each occupy their own position")
// Cross-check against mergeFamilies + numberByDirection on an independent copy: // Each chain is numbered independently, and a DirAny rule counts in the input chain.
// each merged rule's Number must map through the anchors to its physical anchor. chains := []*Rule{
cp := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(FamilyAny, 80)} {Direction: DirInput, Proto: TCP, Port: 22, Action: Accept},
merged := mergeFamilies(cp) {Direction: DirOutput, Proto: TCP, Port: 25, Action: Accept},
numberByDirection(merged) {Direction: DirAny, Proto: TCP, Port: 53, Action: Accept},
require.Len(t, merged, 2) {Direction: DirForward, Proto: TCP, Port: 80, Action: Accept},
require.Equal(t, 1, merged[0].Number) {Direction: DirOutput, Proto: TCP, Port: 443, Action: Accept},
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},
} }
anchors := mergedFamilyAnchors(phys) numberByDirection(chains)
require.Equal(t, []int{0, 1}, anchors, require.Equal(t, 1, chains[0].Number, "first input rule")
"R4 anchors the pair at index 0; the port-80 rule stays at index 1; R6 is absorbed") 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")
// The NAT anchor mapping mirrors the filter one. require.Equal(t, 2, chains[4].Number, "second output rule")
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")
} }
// recordManager is a minimal Manager that records mutations and does NOT // 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.Equal(t, 1, removed)
require.Len(t, m.rules, 2) 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")
}

View file

@ -11,9 +11,6 @@ import (
firewalld "github.com/grmrgecko/go-firewalld" 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 // NewFirewallD connects to firewalld and returns a manager, or an error when
// firewalld cannot be reached. // firewalld cannot be reached.
func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) { 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) rules = append(rules, rule)
} }
// Collapse an IPv4/IPv6 pair of otherwise-identical rules into a single // Every entry above is reported as firewalld stores it. A rich rule with no
// FamilyAny rule, as every other backend's GetRules does, so a rule added // `family=` attribute, and a zone port or source, cover both IP families as one
// family-agnostically reads back the same way. // object and read back as FamilyAny on their own; a rich rule's port element
rules = mergeFamilies(rules) // 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 // 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 // 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() { if r.IsForward() {
return "", unsupportedForward("firewalld") 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 // 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. // not valid, so reject rather than emit a rule firewalld will refuse.
if r.PortNeedsConcreteProtocol() { 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. // DirAny rule cannot be expressed, so it degrades to its input half.
r = dirAnyInputFallback(r, f.Capabilities().Output) 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. // A connection-count limit cannot be expressed in a firewalld rich rule.
if r.ConnLimit != nil { if r.ConnLimit != nil {
return fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit) 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. // mirroring AddRule so a both-directions rule is found and removed as stored.
r = dirAnyInputFallback(r, f.Capabilities().Output) 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. // Get the zone.
zoneName, err := f.resolveZoneName(ctx, zoneName) zoneName, err := f.resolveZoneName(ctx, zoneName)
if err != nil { 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 // 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 // whose parsed form matches the target, so a rule firewalld stored with
// different formatting than our marshaller produces is still matched. Match with // different formatting than our marshaller produces is still matched. Match with
// EqualForRemoval rather than the family-strict Equal: GetRules merges an // EqualForRemoval rather than the family-strict Equal: a FamilyAny target must
// IPv4/IPv6 rich-rule twin (what a concrete-family bare-port accept becomes) // clear both the familyless rich rule it names and any family-pinned rich rules
// into one FamilyAny rule, so removing that read-back rule must clear both // it covers, while a concrete-family target still removes only its own family
// underlying rich rules, while a concrete-family target still removes only its // (splitting a familyless rich rule when it matches one).
// own family.
settings, err := zone.Settings(ctx) settings, err := zone.Settings(ctx)
if err != nil { if err != nil {
return err return err
@ -1309,104 +1338,6 @@ func (f *FirewallD) RemoveNATRule(ctx context.Context, zoneName string, r *NATRu
return fmt.Errorf("invalid nat kind") 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 // policyFromTarget maps a firewalld zone target to a default action. The
// "default"/"%%REJECT%%"/empty targets behave as a reject, the only ones a zone // "default"/"%%REJECT%%"/empty targets behave as a reject, the only ones a zone
// accepts explicitly being ACCEPT and DROP. // accepts explicitly being ACCEPT and DROP.
@ -1599,6 +1530,104 @@ func (f *FirewallD) RemoveAddressSetEntry(ctx context.Context, name, entry strin
return err 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. // Reload reloads firewalld's permanent configuration into the runtime.
func (f *FirewallD) Reload(ctx context.Context) error { func (f *FirewallD) Reload(ctx context.Context) error {
return f.Conn.Reload(ctx) return f.Conn.Reload(ctx)

View file

@ -1,7 +1,6 @@
package firewall package firewall
import ( import (
"errors"
"testing" "testing"
firewalld "github.com/grmrgecko/go-firewalld" firewalld "github.com/grmrgecko/go-firewalld"
@ -87,7 +86,6 @@ func TestFirewallDRichRules(t *testing.T) {
// rejected with the ErrUnsupportedForward sentinel. // rejected with the ErrUnsupportedForward sentinel.
_, err = fw.MarshalRichRule(&Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept}) _, err = fw.MarshalRichRule(&Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept})
require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected") 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) { 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 // 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 // requires FamilyAny), so a family-agnostic port opened per family becomes two rich
// into one FamilyAny rule. RemoveRule must locate that merged rule against the // rules. Removing it with a FamilyAny target must locate both against the stored rich
// stored rich rules with EqualForRemoval — the family-strict Equal it used // rules with EqualForRemoval — the family-strict Equal it used before matched
// before matched neither, so the port stayed open. Regression for the firewalld // neither, so the port stayed open. Regression for the firewalld family-agnostic
// merged-family remove no-op surfaced by the integration suite. // remove no-op surfaced by the integration suite.
func TestFirewallDMergedRichRuleRemovable(t *testing.T) { func TestFirewallDFamilyAnyRichRuleRemovable(t *testing.T) {
fw := new(FirewallD) fw := new(FirewallD)
v4, err := fw.UnmarshalRichRule(`rule family="ipv4" port port="3492" protocol="tcp" accept`) v4, err := fw.UnmarshalRichRule(`rule family="ipv4" port port="3492" protocol="tcp" accept`)
require.NoError(t, err) require.NoError(t, err)
v6, err := fw.UnmarshalRichRule(`rule family="ipv6" port port="3492" protocol="tcp" accept`) v6, err := fw.UnmarshalRichRule(`rule family="ipv6" port port="3492" protocol="tcp" accept`)
require.NoError(t, err) require.NoError(t, err)
merged := mergeFamiliesCopy([]*Rule{v4, v6}) // The two stored rich rules cover the family-agnostic rule between them.
require.Len(t, merged, 1, "the v4/v6 rich-rule twin must collapse into one rule") target := &Rule{Family: FamilyAny, Proto: TCP, Port: 3492, Action: Accept}
m := merged[0] require.True(t, target.CoveredBy([]*Rule{v4, v6}))
require.Equal(t, FamilyAny, m.impliedFamily())
// The old family-strict matcher found neither stored rich rule. // The family-strict matcher finds neither stored rich rule.
require.False(t, m.Equal(v4, false)) require.False(t, target.Equal(v4, false))
require.False(t, m.Equal(v6, false)) require.False(t, target.Equal(v6, false))
// The new matcher (EqualForRemoval) finds both, so RemoveRule clears both rich // EqualForRemoval finds both, so RemoveRule clears every rich rule the target
// rules for a merged read-back rule. // covers.
require.True(t, v4.EqualForRemoval(m, false)) require.True(t, v4.EqualForRemoval(target, false))
require.True(t, v6.EqualForRemoval(m, 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 // 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") 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 // firewalld expresses the portless protocols as a bare protocol element and
// SCTP as a port protocol; both round-trip through a rich rule. // SCTP as a port protocol; both round-trip through a rich rule.
func TestFirewallDProtocolExtras(t *testing.T) { func TestFirewallDProtocolExtras(t *testing.T) {

View file

@ -49,6 +49,21 @@ func ruleNeedsHook(r *Rule) bool {
isSetRef(r.Source) || isSetRef(r.Destination) 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 // 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, // 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 // 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 != "" { if r.Source != "" && r.Destination != "" {
return true 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 != "" { if r.Source != "" || r.Destination != "" {
return r.Proto == TCP || r.Proto == UDP return onProtocolAxis(r.Proto)
} }
return false return false
} }
@ -130,16 +147,15 @@ func hookOnlyProto(p Protocol) bool {
} }
// hookRuleProtos lists the transport protocols a rule is written for in the hook. // 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/ // iptables has no both-transports match, so a TCPUDP rule fans out into a tcp line
// --sport match requires a concrete -p tcp/udp — so it fans out into a tcp line and // and a udp line; every other protocol writes one line. A portless ProtocolAny rule
// a udp line, mirroring the tcp+udp fan-out csf/apf write for a ProtocolAny port // is a valid protocol-agnostic match (a bare `-j ACCEPT`) and is not fanned.
// 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.
func hookRuleProtos(r *Rule) []Protocol { func hookRuleProtos(r *Rule) []Protocol {
if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) { protos := make([]Protocol, 0, 2)
return []Protocol{TCP, UDP} 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 // hookRuleFamilies lists the address families a rule is written for: a rule

View file

@ -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 — // bareProtoNeedsHook routes a portless, addressless non-ICMP match to the hook —
// the shape CSF/APF cannot express natively but iptables applies directly — while // 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 // 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") 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 // A TCPUDP rule has no single iptables form — one line matches one -p — so the hook
// match requires a concrete -p tcp/udp — so the hook fans it out into a tcp line and // fans it out into a tcp line and a udp line, mirroring the tcp+udp fan-out csf/apf
// a udp line, mirroring the tcp+udp fan-out csf/apf write in their native config. // write in their native config. Both add and remove must fan out and never reject
// Both add and remove must fan out and never reject the rule for want of a concrete // the rule for want of a concrete protocol. Regression: a Backup could hold a TCPUDP
// protocol. Regression: csf's GetRules merges a same-port TCP_IN/UDP_IN pair back // rule, and Restore's hook-copy clear then failed to marshal it, breaking the whole
// into one ProtocolAny rule, so a Backup captured it and Restore's hook-copy clear // restore.
// then failed to marshal it, breaking the whole restore. func TestHookScriptTCPUDPPortFansOut(t *testing.T) {
func TestHookScriptProtocolAnyPortFansOut(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
h := &hookScript{ h := &hookScript{
rulePrefix: "go_firewall", rulePrefix: "go_firewall",
@ -120,30 +162,30 @@ func TestHookScriptProtocolAnyPortFansOut(t *testing.T) {
hookPerm: 0700, hookPerm: 0700,
} }
// Adding a ProtocolAny port rule injects a tcp line and a udp line. // Adding a TCPUDP port rule injects a tcp line and a udp line.
any := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 20, Action: Accept} any := &Rule{Family: IPv4, Proto: TCPUDP, Port: 20, Action: Accept}
changed, err := h.edit(any, false) 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) require.True(t, changed)
got, err := h.getRules() got, err := h.getRules()
require.NoError(t, err) 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{} protos := map[Protocol]bool{}
for _, g := range got { for _, g := range got {
protos[g.Proto] = true protos[g.Proto] = true
} }
require.True(t, protos[TCP] && protos[UDP], "the fan-out must cover both tcp and udp: %+v", got) 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. // erroring on the port-without-concrete-protocol shape.
changed, err = h.edit(any, true) changed, err = h.edit(any, true)
require.NoError(t, err, "removing a ProtocolAny port rule must not fail to marshal") require.NoError(t, err, "removing a TCPUDP port rule must not fail to marshal")
require.True(t, changed, "the ProtocolAny remove must clear the tcp and udp copies") require.True(t, changed, "the TCPUDP remove must clear the tcp and udp copies")
got, err = h.getRules() got, err = h.getRules()
require.NoError(t, err) 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 // A deny whose action differs from the CSF/APF config's STOP action has no native

View file

@ -24,3 +24,18 @@ func linuxBackends() []backendFactory {
func TestIntegration(t *testing.T) { func TestIntegration(t *testing.T) {
runIntegration(t, linuxBackends()) 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
}

View file

@ -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
}

View file

@ -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 // 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 // csf/apf address-list backends carry a pre-hook, so plant the rule directly in
// theirs to stand in for the hand-added copy. // theirs to stand in for the hand-added copy.
var plant func(*Rule) error plant := hookPlanter(mgr)
switch b := mgr.(type) { if plant == nil {
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:
t.Skip("backend has no pre-hook") 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) { t.Run("familypairremove", func(t *testing.T) {
// A v4 rule and its v6 twin differ only in Family, so GetRules collapses the // A v4 rule and its v6 twin are two rows on most backends. Removing every rule
// pair into one FamilyAny rule. Removing that read-back rule must clear BOTH // the backend reports must clear them all. Regression for a family-strict
// underlying rows. Regression for a family-strict remove that could not match // remove that could not match a family-agnostic rule at all (a silent no-op
// a merged rule at all (a silent no-op that left the port open) or that // that left the port open) or that removed only one of the two rows.
// removed only one of the two rows.
const port = 3492 const port = 3492
v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept} v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept}
v6 := &Rule{Family: IPv6, 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)) require.NoError(t, mgr.AddRule(ctx, zone, v4))
} }
// Remove each rule the backend reports for this port (one merged FamilyAny // Remove each rule the backend reports for this port (one family-agnostic rule,
// rule, or one per family), then confirm none remain — removing the merged // or one per family), then confirm none remain — a removal must not leave a
// rule must not leave a twin row behind. // twin row behind.
for _, r := range rulesOf(t, ctx, mgr, zone) { for _, r := range rulesOf(t, ctx, mgr, zone) {
if portRule(r) { if portRule(r) {
require.NoError(t, mgr.RemoveRule(ctx, zone, 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) { 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 // 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 // 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; // 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) }) t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
} }
ports := []uint16{before, dual, after} // A family-separated backend stores the dual rule as a v4 row and a v6 row, so
order0 := managedPorts(t, ctx, mgr, zone, ports) // 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") 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. // 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.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") "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) { t.Run("diranysplitremove", func(t *testing.T) {
// A DirAny rule applies in BOTH directions. On the chain backends it is stored // 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 // 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 { switch r.Direction {
case DirAny: 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() { if addrEqual(r.Source, host) && soleDest() {
return true, true 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) { t.Run("diranyroundtrip", func(t *testing.T) {
// A DirAny rule added and read back must round-trip as exactly one DirAny rule, // A DirAny rule reads back as whatever the backend stores: one bidirectional
// and a second add of the same rule must be an idempotent no-op (the merged // line where its config has that form (csf.allow, apf's allow_hosts), otherwise
// DirAny rule dedups a re-add) rather than doubling the underlying rows. // 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) requireCap(t, caps.Output)
rule := &Rule{Direction: DirAny, Source: "192.0.2.78", Action: Accept} rule := &Rule{Direction: DirAny, Source: "192.0.2.78", Action: Accept}
if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) { 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) }) t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
matches := func() int { matches := func() []*Rule {
n := 0 var out []*Rule
for _, r := range rulesOf(t, ctx, mgr, zone) { for _, r := range rulesOf(t, ctx, mgr, zone) {
if r.Direction == DirAny && addrEqual(r.Source, "192.0.2.78") { if addrEqual(r.Source, "192.0.2.78") || addrEqual(r.Destination, "192.0.2.78") {
n++ 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.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) { t.Run("diranyported", func(t *testing.T) {
// A DirAny rule that is NOT a bare host — here a host + destination port — must // 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- // 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. // swapped outbound (sport) twin, which together cover the rule on read. This
// This exercises the fan-out path that a plain csf.allow/apf line (bare host) // exercises the fan-out path that a plain csf.allow/apf line (bare host) does
// does not use. Skip where the backend cannot express the shape. // not use. Skip where the backend cannot express the shape.
requireCap(t, caps.Output) requireCap(t, caps.Output)
rule := &Rule{Direction: DirAny, Proto: TCP, Port: 22, Source: "192.0.2.80", Action: Accept} 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) { 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) require.NoError(t, err)
} }
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, caps.Output), require.True(t, rule.CoveredBy(rulesOf(t, ctx, mgr, zone)),
"a DirAny ported host rule must read back as one DirAny rule") "the stored rows must cover both directions of the ported host rule")
require.NoError(t, mgr.RemoveRule(ctx, zone, 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") "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}) 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) { t.Run("reloadv6raw", func(t *testing.T) {
requireCap(t, caps.ICMPv6) requireCap(t, caps.ICMPv6)
// An IPv6 rule a backend keeps in its own IPv6 rule file must survive an // 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) requireCap(t, caps.NAT)
// A v4 masquerade and its v6 twin on the same interface differ only in Family, // A v4 masquerade and its v6 twin on the same interface may be one row (nft's
// so GetNATRules collapses the pair into one FamilyAny rule (mergeNATFamilies). // inet table, a pf rule with no af) or two. Removing every rule the backend
// Removing that read-back rule must clear BOTH underlying rows. Regression for // reports must clear them all. Regression for a NAT remove that stopped at the
// a NAT remove that stopped at the first match and left the IPv6 twin loaded // first match and left the IPv6 twin loaded (pf), mirroring the filter-side
// (pf), mirroring the filter-side mergedfamilyremove probe. // familypairremove probe.
v4 := &NATRule{Kind: Masquerade, Family: IPv4, Interface: "eth1"} v4 := &NATRule{Kind: Masquerade, Family: IPv4, Interface: "eth1"}
v6 := &NATRule{Kind: Masquerade, Family: IPv6, Interface: "eth1"} v6 := &NATRule{Kind: Masquerade, Family: IPv6, Interface: "eth1"}
added := 0 added := 0
@ -1017,15 +1166,15 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
added++ added++
} }
if added < 2 { 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() { t.Cleanup(func() {
_ = mgr.RemoveNATRule(ctx, zone, v4) _ = mgr.RemoveNATRule(ctx, zone, v4)
_ = mgr.RemoveNATRule(ctx, zone, v6) _ = mgr.RemoveNATRule(ctx, zone, v6)
}) })
// Remove every masquerade the backend reports for this interface (one merged // Remove every masquerade the backend reports for this interface (one
// FamilyAny rule, or one per family), then confirm none remain. // family-agnostic rule, or one per family), then confirm none remain.
isMasq := func(r *NATRule) bool { return r.Kind == Masquerade && r.Interface == "eth1" } isMasq := func(r *NATRule) bool { return r.Kind == Masquerade && r.Interface == "eth1" }
nats, err := mgr.GetNATRules(ctx, zone) nats, err := mgr.GetNATRules(ctx, zone)
require.NoError(t, err) 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})) 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) requireCap(t, caps.RuleOrdering)
// A v4/v6 twin collapses to one logical rule that occupies two physical rows. // A v4 rule and its v6 twin may occupy two physical rows. Moving them with one
// Moving it must relocate BOTH rows as a unit: on backends that map a merged // FamilyAny target must relocate BOTH as a unit: a naive move drags only one
// position to a physical index, a naive move drags only one row and orphans a // row and orphans the twin at its old position.
// concrete-family twin (grows the chain, splits the merged rule).
const port = 3510 const port = 3510
v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept} v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept}
v6 := &Rule{Family: IPv6, 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) _ = mgr.RemoveRule(ctx, zone, b)
}) })
// The pair merges, so the chain reads as two logical rules. before := managedPorts(t, ctx, mgr, zone, []uint16{port})
merged := findRule(t, ctx, mgr, zone, v4) require.NotEmpty(t, before)
require.Equal(t, FamilyAny, merged.Family, "the v4/v6 pair should read back as one FamilyAny rule")
// 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}) order := managedPorts(t, ctx, mgr, zone, []uint16{port, 3511})
require.Equal(t, []uint16{3511, port}, order, require.NotEmpty(t, 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, uint16(3511), order[0],
require.Equal(t, FamilyAny, findRule(t, ctx, mgr, zone, v4).Family, "b must now be first: every row of the pair moved past it")
"the moved twin must stay merged, not leave a concrete-family orphan") 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) requireCap(t, caps.RuleOrdering)
// GetRules numbers logical (merged) rules, but the chain holds physical rows. // GetRules reports one rule per stored row, each with the Number of its own
// Inserting before a rule's reported Number must land at that logical position: // position. Inserting before a rule's reported Number must land exactly there,
// with merged pairs preceding the target, a naive position-1 against physical // whatever rows precede it.
// rows skews the placement by the number of absorbed twins.
a4 := &Rule{Family: IPv4, Proto: TCP, Port: 3520, Action: Accept} a4 := &Rule{Family: IPv4, Proto: TCP, Port: 3520, Action: Accept}
a6 := &Rule{Family: IPv6, 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} 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++ added++
} }
if added < 4 { 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} c := &Rule{Family: IPv4, Proto: TCP, Port: 3522, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, c)) 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 // Read c's Number (its position, whatever the backend's add order) and insert
// (its logical position, whatever the backend's add order) and insert d there. // d there. c is IPv4-only, so it reads back as exactly one rule.
nums := managedNumbers(t, ctx, mgr, zone, []uint16{3522}) 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] cNum := nums[0]
d := &Rule{Family: IPv4, Proto: TCP, Port: 3523, Action: Accept} d := &Rule{Family: IPv4, Proto: TCP, Port: 3523, Action: Accept}
require.NoError(t, mgr.InsertRule(ctx, zone, cNum, d)) require.NoError(t, mgr.InsertRule(ctx, zone, cNum, d))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, 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}) 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 var ci int
for i, p := range order { for i, p := range order {
if p == 3522 { 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.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 --------------------------------------------------------- // --- NAT ordering ---------------------------------------------------------

File diff suppressed because it is too large Load diff

View file

@ -366,14 +366,6 @@ func TestIPTablesDefaultPolicyIgnoresNATTable(t *testing.T) {
"the nat table's built-in chains must stay ACCEPT after a policy write") "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 // TestIPTablesLayoutDetection covers probeRHELLayout/probeDebianLayout's file
// presence rules directly, without a live D-Bus/systemd (which NewIPTables // presence rules directly, without a live D-Bus/systemd (which NewIPTables
// itself requires and which these helpers do not touch). // 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)) require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "sysconfig", "ip6tables"), nil, 0644))
l, ok := probeRHELLayout(root) l, ok := probeRHELLayout(root)
require.True(t, ok, "the RHEL layout should be found when both save files are present") 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, "iptables", l.ip4Service)
require.Equal(t, "ip6tables.service", l.ip6Service) 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, "/etc/sysconfig/ipset", l.ipsetPath, "the RHEL layout persists sets to the ipset-service compat file")
require.Equal(t, "ipset.service", l.ipsetService) require.Equal(t, "ipset", l.ipsetService)
require.Empty(t, l.ipsetPlugin, "the RHEL layout gates on the ipset.service unit, not a plugin file") 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. // RHEL layout: v4 present but v6 missing is an incomplete pair, not a match.
root = t.TempDir() 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)) require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "iptables", "rules.v6"), nil, 0644))
l, ok = probeDebianLayout(root) l, ok = probeDebianLayout(root)
require.True(t, ok, "the Debian layout should be found when both save files are present") 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, "netfilter-persistent", l.ip4Service)
require.Equal(t, l.ip4Service, l.ip6Service, "the Debian layout restores both families from one unit") 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.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") 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") 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 // iptables keeps each family in its own save file, so an IPv6 rule's position counts
// contiguous and unique within a direction rather than carrying the per-family // only the ip6tables chain it lives in. GetRules must number the two families
// numbering forward (which left gaps/duplicates once families were combined). // independently — numbering the concatenation would offset every IPv6 rule by the
func TestIPTablesMergedRulesRenumberContiguously(t *testing.T) { // length of the IPv4 chain and send InsertRule/MoveRule to the wrong line.
func TestIPTablesNumbersEachFamilyChainIndependently(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n"
p4 := filepath.Join(dir, "iptables") p4 := filepath.Join(dir, "iptables")
@ -646,36 +639,29 @@ func TestIPTablesMergedRulesRenumberContiguously(t *testing.T) {
f := &IPTables{IP4Path: p4, IP6Path: p6} f := &IPTables{IP4Path: p4, IP6Path: p6}
ctx := context.Background() ctx := context.Background()
// A mergeable v4/v6 pair (port 22) plus a v4-only and a v6-only rule. All // Two rules per family, all input direction.
// input direction. After merge there are three rules: one FamilyAny, one IPv4, for _, r := range []*Rule{
// one IPv6.
rules := []*Rule{
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 22, Proto: TCP, Action: Accept},
{Family: IPv6, Port: 22, Proto: TCP, Action: Accept}, {Family: IPv6, Port: 22, Proto: TCP, Action: Accept},
{Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 80, Proto: TCP, Action: Accept},
{Family: IPv6, Port: 443, Proto: TCP, Action: Accept}, {Family: IPv6, Port: 443, Proto: TCP, Action: Accept},
} } {
for _, r := range rules {
require.NoError(t, f.AddRule(ctx, "", r)) require.NoError(t, f.AddRule(ctx, "", r))
} }
got, err := f.GetRules(ctx, "") got, err := f.GetRules(ctx, "")
require.NoError(t, err) 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 // Within each family's INPUT chain the numbers run 1..2, and no rule is reported
// {1,2,3} — no gaps, no cross-family duplicates. // as FamilyAny — a save-file line always belongs to exactly one family.
nums := map[int]bool{} perFamily := map[Family][]int{}
var family22 Family = 255
for _, r := range got { for _, r := range got {
require.False(t, nums[r.Number], "duplicate Number %d across merged families", r.Number) require.NotEqual(t, FamilyAny, r.Family, "an iptables line always names one family")
nums[r.Number] = true perFamily[r.Family] = append(perFamily[r.Family], r.Number)
if r.Port == 22 {
family22 = r.Family
}
} }
require.Equal(t, FamilyAny, family22, "the merged port-22 rule is family-agnostic") require.Equal(t, []int{1, 2}, perFamily[IPv4], "the IPv4 INPUT chain numbers 1..2")
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[IPv6], "the IPv6 INPUT chain numbers 1..2, not 3..4")
} }
// GetDefaultPolicy reads both family save files: it returns the shared policy // 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. // tempIPTables builds an iptables backend backed by scratch save files.
func tempIPTables(t *testing.T) *IPTables { func tempIPTables(t *testing.T) *IPTables {
t.Helper() 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) 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")
}

View file

@ -11,8 +11,6 @@ import (
) )
const ( 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 is the table name used when no rule prefix is supplied.
NFTDefaultTable = "go_firewall" NFTDefaultTable = "go_firewall"
) )
@ -288,6 +286,26 @@ func (f *NFT) protoFromToken(tok string) Protocol {
return ProtocolAny 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 // stripSetRef drops the '@' nft prints before a named-set reference, yielding
// the bare set name the Rule model stores in Source/Destination. // the bare set name the Rule model stores in Source/Destination.
func (f *NFT) stripSetRef(v string) string { 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) r.ICMPType = Ptr(n)
} }
case "tcp", "udp", "sctp": case "tcp", "udp", "sctp", "th":
r.Proto = GetProtocol(tokens[i]) // `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. // A `dport` or `sport` qualifier may follow.
if i+2 < len(tokens) && (tokens[i+1] == "dport" || tokens[i+1] == "sport") { if i+2 < len(tokens) && (tokens[i+1] == "dport" || tokens[i+1] == "sport") {
src := 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": case "meta":
// meta l4proto <proto> or meta nfproto <family> // meta l4proto <proto|set> or meta nfproto <family>
if i+2 >= len(tokens) { if i+2 >= len(tokens) {
return nil, "", fmt.Errorf("unsupported meta match") return nil, "", fmt.Errorf("unsupported meta match")
} }
switch tokens[i+1] { switch tokens[i+1] {
case "l4proto": 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": case "nfproto":
switch tokens[i+2] { switch tokens[i+2] {
case "ipv4": case "ipv4":
@ -667,9 +694,14 @@ func (f *NFT) listChain(ctx context.Context, chain string) (rules []*Rule, handl
return rules, handles, nil return rules, handles, nil
} }
// listOwnRules returns the library's own filter rules from its private table, // listOwnRules returns the library's own filter rules from its private table, one
// with the IPv4/IPv6 pairs merged. A read does not create the table; listChain // rule per physical chain row. A read does not create the table; listChain returns
// returns nothing when the table does not yet exist. // 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) { func (f *NFT) listOwnRules(ctx context.Context) ([]*Rule, error) {
var rules []*Rule var rules []*Rule
for _, chain := range nftFilterChains { for _, chain := range nftFilterChains {
@ -679,21 +711,13 @@ func (f *NFT) listOwnRules(ctx context.Context) ([]*Rule, error) {
} }
rules = append(rules, chainRules...) rules = append(rules, chainRules...)
} }
// Merge families first, number per direction (input then output chain) so a numberByDirection(rules)
// collapsed v4/v6 pair leaves no gap and each rule's Number matches the return rules, nil
// 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
} }
// GetRules returns the existing filter rules from the zone. // GetRules returns the existing filter rules from the zone.
func (f *NFT) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { 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 // The library's own rules, then foreign rules from every other table.
// unmerged (their identity spans tables we do not own).
rules, err = f.listOwnRules(ctx) rules, err = f.listOwnRules(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@ -755,10 +779,19 @@ func (f *NFT) l3Match(family Family, addr string) string {
return "ip" 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`. // 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 // 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. // 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 { func (f *NFT) l4Proto(p Protocol) string {
if p == TCPUDP {
return nftTCPUDPSet
}
return p.String() 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))) parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(r.Family, r.Destination), f.addrExpr(r.Destination)))
} }
// Protocol / port match. // Protocol / port match. A TCPUDP rule pins both transports with an anonymous
if r.HasPorts() { // set and matches the port through `th`, the transport-header selector, which is
// A concrete protocol is guaranteed by the check above. // valid precisely because l4proto is constrained to port-carrying protocols. That
parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs()))) // 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() 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 { 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 { if r.Proto.IsICMP() && r.ICMPType != nil {
// An ICMP type match implies the icmp/icmpv6 protocol. // 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" kw = "icmpv6"
} }
parts = append(parts, fmt.Sprintf("%s type %d", kw, *r.ICMPType)) 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)) 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 { if position >= 1 {
// position is a merged Number; map it back to a physical chain index so a // Each chain row is its own rule, so a Number is already a physical index;
// merged v4/v6 pair earlier in the chain does not skew the placement. // a position past the last row appends.
insPos := mergedInsertIndex(mergedFamilyAnchors(existing), len(existing), position) insPos := min(position-1, len(existing))
args := f.placeArgs(chain, expr, insPos, len(existing)) args := f.placeArgs(chain, expr, insPos, len(existing))
_, err = runCommand(ctx, "nft", args...) _, err = runCommand(ctx, "nft", args...)
return err return err
@ -1096,45 +1138,32 @@ func (f *NFT) MoveRule(ctx context.Context, zoneName string, r *Rule, position i
return err return err
} }
// Collect every row that belongs to this rule. GetRules merges an IPv4 and IPv6 // Collect every row this rule covers. A FamilyAny or TCPUDP target spans rows the
// row that differ only in family into one FamilyAny rule, so moving that merged // chain may hold separately (an IPv4 row and an IPv6 row added one at a time), so
// rule must relocate BOTH underlying rows. Deleting only the first half and // moving it must relocate all of them; relocating only the first would orphan the
// re-inserting a single dual-family rule would orphan the twin — the same hazard // rest — the same hazard RemoveRule guards against via EqualForRemoval. A
// RemoveRule guards against via EqualForRemoval. A concrete-family target // concrete-family target still moves only its own family.
// still moves only its own family.
firstIdx := -1 firstIdx := -1
var toDelete []string var toDelete []string
deleted := make(map[int]bool)
for i, e := range rules { for i, e := range rules {
if e.EqualForRemoval(r, true) { if e.EqualForRemoval(r, true) {
if firstIdx < 0 { if firstIdx < 0 {
firstIdx = i firstIdx = i
} }
toDelete = append(toDelete, handles[i]) toDelete = append(toDelete, handles[i])
deleted[i] = true
} }
} }
if firstIdx < 0 { if firstIdx < 0 {
return nil return nil
} }
// position and each rule's Number live in the merged (post-mergeFamilies) index // Each chain row is its own rule, so a position is a physical index. The target's
// space, while the chain holds physical rows; map through the chain's merged // current position is that of its first matching row.
// anchors so a merged v4/v6 pair does not skew the target. curPos is the target if position > len(rules) {
// rule's current merged position (its anchor is firstIdx, the first matching row). position = len(rules)
anchors := mergedFamilyAnchors(rules)
if position > len(anchors) {
position = len(anchors)
}
curPos := 0
for p, idx := range anchors {
if idx == firstIdx {
curPos = p + 1
break
}
} }
// If moving to the same position, nothing to do. // If moving to the same position, nothing to do.
if position == curPos { if position == firstIdx+1 {
return nil return nil
} }
@ -1150,17 +1179,11 @@ func (f *NFT) MoveRule(ctx context.Context, zoneName string, r *Rule, position i
if err != nil { if err != nil {
return err return err
} }
// The rows were just deleted, so the chain now holds the remaining rows; map the // The rows were just deleted, so the chain now holds the remaining rows; clamp the
// target merged position to a physical index within that reduced chain (appending // target position to that reduced chain (appending when it is at or past the end).
// when the target is at or past the end). remaining := len(rules) - len(toDelete)
reduced := make([]*Rule, 0, len(rules)-len(toDelete)) insPos := min(position-1, remaining)
for i, e := range rules { args := f.placeArgs(chain, expr, insPos, remaining)
if !deleted[i] {
reduced = append(reduced, e)
}
}
insPos := mergedInsertIndex(mergedFamilyAnchors(reduced), len(reduced), position)
args := f.placeArgs(chain, expr, insPos, len(reduced))
_, err = runCommand(ctx, "nft", args...) _, err = runCommand(ctx, "nft", args...)
return err return err
} }
@ -1189,39 +1212,33 @@ func (f *NFT) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
if err != nil { if err != nil {
return err return err
} }
// Delete every matching row, not just the first. GetRules merges an IPv4 and // Delete every row the target covers, not just the first: a FamilyAny target
// IPv6 row that differ only in family into one FamilyAny rule, so removing that // clears both an unpinned row and any family-pinned rows it spans, and a TCPUDP
// merged rule must clear both underlying rows or its twin is orphaned (and a // target clears both transports. A concrete-family target still removes only its
// second reconcile pass would be needed to converge). A concrete-family target // own family — see EqualForRemoval.
// still removes only its own family — see EqualForRemoval. var reAdd []*Rule
// 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
reAddPos := 0 reAddPos := 0
for i, e := range rules { for i, e := range rules {
if e.EqualForRemoval(r, true) { if e.EqualForRemoval(r, true) {
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil { if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil {
return err return err
} }
// A concrete-family target that matched a genuine dual-family row (an inet // A concrete target that matched a multi-state row would drop coverage the
// rule with no family pin, covering both) would drop both families; re-add // caller never asked to remove: an unpinned inet row covers both families, and
// the untargeted family below, at the dual row's own merged position so the // a `meta l4proto { tcp, udp }` row both transports. Re-add the remainder at
// surviving family keeps the removed rule's place in the chain. // the row's own position so it keeps the removed rule's place in the chain.
if s := splitDualRow(e, r); s != nil { if s := splitMergedRow(e, r); len(s) > 0 {
reAdd = s reAdd = s
for p, idx := range anchors { reAddPos = i + 1
if idx == i {
reAddPos = p + 1
break
}
}
} }
} }
} }
if reAdd != nil { // Re-add in order so the remainder rows keep the removed row's position; each
return f.insertRule(ctx, zoneName, reAddPos, reAdd) // 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. // Nothing left to remove.
return nil return nil
@ -1483,8 +1500,8 @@ func (f *NFT) listNATChain(ctx context.Context, chain string) (rules []*NATRule,
return rules, handles, nil return rules, handles, nil
} }
// listOwnNATRules returns the library's own NAT rules from its private table, // listOwnNATRules returns the library's own NAT rules from its private table, one
// with the IPv4/IPv6 pairs merged. // rule per physical chain row.
func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) { func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) {
var rules []*NATRule var rules []*NATRule
for _, chain := range []string{"prerouting", "postrouting"} { for _, chain := range []string{"prerouting", "postrouting"} {
@ -1494,12 +1511,12 @@ func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) {
} }
rules = append(rules, chainRules...) rules = append(rules, chainRules...)
} }
// Merge families first, then number per nat chain (prerouting then postrouting) // The nat chains live in the same inet table, so a family-agnostic translation is
// so a collapsed v4/v6 pair leaves no gap and each rule's Number matches the // 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. // InsertNATRule position within its chain.
merged := mergeNATFamilies(rules) numberNATByChain(rules)
numberNATByChain(merged) return rules, nil
return merged, nil
} }
// GetNATRules returns the existing NAT rules from the zone. // 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 { if position <= 0 {
position = 1 position = 1
} }
// position is a merged Number; map it back to a physical nat-chain index so a // Each nat-chain row is its own rule, so a Number is already a physical index; a
// merged v4/v6 NAT pair earlier in the chain does not skew the placement. // position past the last row appends.
insPos := mergedInsertIndex(mergedNATFamilyAnchors(existing), len(existing), position) insPos := min(position-1, len(existing))
args := f.placeArgs(chain, expr, insPos, len(existing)) args := f.placeArgs(chain, expr, insPos, len(existing))
_, err = runCommand(ctx, "nft", args...) _, err = runCommand(ctx, "nft", args...)
return err return err
@ -1718,9 +1735,9 @@ func (f *NFT) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) er
if err != nil { if err != nil {
return err return err
} }
// Delete every matching row (see RemoveRule): a merged FamilyAny NAT rule must // Delete every matching row (see RemoveRule): a FamilyAny NAT target must clear
// clear both its v4 and v6 rows, while a concrete-family target removes only // both the unpinned row it names and any family-pinned rows it covers, while a
// its own family. // concrete-family target removes only its own family.
for i, e := range rules { for i, e := range rules {
if e.EqualForRemoval(r) { if e.EqualForRemoval(r) {
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil { 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 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 // 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 // ActionInvalid when the table or chain does not yet exist (no policy to
// report) or the policy is not recognized. // report) or the policy is not recognized.
@ -2137,6 +2095,65 @@ func (f *NFT) RemoveAddressSetEntry(ctx context.Context, name, entry string) err
return 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. // Reload is a no-op; nftables applies changes immediately, so there is nothing to reload.
func (f *NFT) Reload(ctx context.Context) error { func (f *NFT) Reload(ctx context.Context) error {
return nil return nil

View file

@ -203,20 +203,6 @@ func TestNFTCounters(t *testing.T) {
require.Zero(t, r2.Bytes) 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 // collapseSetSpaces must not collapse the spaces inside brace-like content
// that lives inside a quoted comment (it is not a real anonymous set), while // 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 // 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) 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 ...`) // `nft -a list ruleset` appends `{ # handle N` (and sometimes ` progname ...`)
// to table/chain header lines. headerName must return the bare object name so // to table/chain header lines. headerName must return the bare object name so
// listForeignRules skips our own table; a naive TrimSuffix(line, "{") left the // 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 // 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 // and lists back double-quoted (log prefix "block'"). The old trimQuotes stripped
// leading/trailing ' as well as the surrounding ", corrupting it to "block" and // 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) 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)
}

334
pf.go
View file

@ -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 // compactRules drops the opaque (nil) placeholder rows parseAnchorRules keeps
// for lines it cannot model, leaving only the rules the library represents. The // 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 // read/number and backup paths use it, since those operate on the modeled rule set
// rule set (a []*Rule cannot carry an unparseable line). // (a []*Rule cannot carry an unparseable line).
func (f *PF) compactRules(rules []*Rule) []*Rule { func (f *PF) compactRules(rules []*Rule) []*Rule {
out := make([]*Rule, 0, len(rules)) out := make([]*Rule, 0, len(rules))
for _, r := range 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 // Drop the opaque placeholder rows kept for unmodeled anchor lines; GetRules
// reports only the rules the library can represent. // reports only the rules the library can represent.
rules = f.compactRules(rules) rules = f.compactRules(rules)
// Merge the library's own IPv4/IPv6 pairs, then number the anchor's rules as one // Report one rule per anchor row. A pf rule written without `inet`/`inet6` matches
// ordered list (pf evaluates a single filter list, so its position spans // both families, and UnmarshalRule reports it as FamilyAny from that single row;
// directions). Numbering after the merge keeps a collapsed pair from leaving a // the transport and direction axes have no both-states form in pf's grammar (this
// gap. Foreign rules appended below live outside this anchor and keep Number 0. // backend fans TCPUDP and DirAny out on write), so those rows read back concrete.
rules = mergeFamilies(rules) // 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) 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)...) rules = append(rules, f.listForeignRules(ctx)...)
return rules, nil return rules, nil
} }
@ -776,6 +775,13 @@ func (f *PF) MarshalRule(r *Rule) (string, error) {
if r.IsForward() { if r.IsForward() {
return "", unsupportedForward("pf") 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. // pf can only match a port alongside a concrete transport protocol.
if r.PortNeedsConcreteProtocol() { if r.PortNeedsConcreteProtocol() {
return "", fmt.Errorf("a port requires a tcp, udp or sctp protocol") 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 ( 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 is the pf anchor name used when no rule prefix is supplied.
PFDefaultAnchor = "go_firewall" PFDefaultAnchor = "go_firewall"
// PFConf is the main pf configuration file. // 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 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) line, err := f.MarshalRule(r)
if err != nil { if err != nil {
return err return err
@ -1280,23 +1295,18 @@ func (f *PF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
return f.loadAnchor(ctx, natRaw, filterRaw) return f.loadAnchor(ctx, natRaw, filterRaw)
} }
// filterAnchors maps each logical (merged) filter rule to its physical row index // filterAnchors maps each logical filter rule to its physical row index in the
// in the anchor, skipping opaque (nil) rows so an unmodeled foreign line occupying // anchor, skipping opaque (nil) rows so an unmodeled foreign line occupying a
// a physical slot does not consume a logical position. With no opaque rows it // physical slot does not consume a logical position. Every modeled row is its own
// equals mergedFamilyAnchors. It backs the merged-position insert/move mapping. // 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 { func (f *PF) filterAnchors(rules []*Rule) []int {
phys := make([]int, 0, len(rules)) anchors := make([]int, 0, len(rules))
modeled := make([]*Rule, 0, len(rules))
for i, r := range rules { for i, r := range rules {
if r == nil { if r == nil {
continue continue
} }
phys = append(phys, i) anchors = append(anchors, i)
modeled = append(modeled, r)
}
anchors := mergedFamilyAnchors(modeled)
for k := range anchors {
anchors[k] = phys[anchors[k]]
} }
return anchors return anchors
} }
@ -1319,6 +1329,17 @@ func (f *PF) InsertRule(ctx context.Context, zoneName string, position int, r *R
return nil 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) line, err := f.MarshalRule(r)
if err != nil { if err != nil {
return err return err
@ -1337,13 +1358,10 @@ func (f *PF) InsertRule(ctx context.Context, zoneName string, position int, r *R
if position <= 0 { if position <= 0 {
position = 1 position = 1
} }
// position is a merged Number: GetRules collapses IPv4/IPv6 pairs and numbers // position is a Number GetRules reported, which counts only the rules it can
// the result, while the anchor holds one physical row per rule. Map through the // model. rules is 1:1 with filterRaw, and filterAnchors skips any opaque
// merged anchors so a collapsed pair earlier in the list does not skew the // (unmodeled) row so a foreign anchor line does not consume a logical position.
// placement (and does not split a family pair). rules is 1:1 with filterRaw, and idx := logicalInsertIndex(f.filterAnchors(rules), len(filterRaw), position)
// filterAnchors skips any opaque (unmodeled) row so it does not consume a
// logical position.
idx := mergedInsertIndex(f.filterAnchors(rules), len(filterRaw), position)
filterRaw = append(filterRaw[:idx], append([]string{line}, filterRaw[idx:]...)...) filterRaw = append(filterRaw[:idx], append([]string{line}, filterRaw[idx:]...)...)
_, natRaw, err := f.anchorNATRules(ctx) _, 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 // 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 // relocated to the 1-based position, and whether any row moved. A FamilyAny or
// back by GetRules can be a collapsed IPv4/IPv6 pair spanning two physical rows, so // TCPUDP target spans rows the anchor may hold separately, so every matching row is
// both twin rows are relocated together. rules is 1:1 with filterRaw. The target // relocated together. rules is 1:1 with filterRaw. The target position counts only
// position lives in the merged (post-mergeFamilies) index space, so it is mapped to // modeled rules, so it is mapped to a physical index within the reduced row set.
// a physical index within the reduced row set.
func (f *PF) reorderRows(rules []*Rule, filterRaw []string, r *Rule, position int) ([]string, bool) { func (f *PF) reorderRows(rules []*Rule, filterRaw []string, r *Rule, position int) ([]string, bool) {
if position <= 0 { if position <= 0 {
position = 1 position = 1
} }
// Split the rows into the ones being moved and the ones staying, keeping the // 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 // 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 // EqualForRemoval (as RemoveRule does), not the family-strict Equal, so a
// never match a merged rule the caller read back; a concrete-family target still // FamilyAny target relocates the family-agnostic row it names; a concrete-family
// moves only its own family row. // target still moves only its own family row.
moved := make([]string, 0, 2) moved := make([]string, 0, 2)
kept := make([]string, 0, len(filterRaw)) kept := make([]string, 0, len(filterRaw))
keptRules := make([]*Rule, 0, len(rules)) 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 { if len(moved) == 0 {
return nil, false 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 := make([]string, 0, len(filterRaw))
out = append(out, kept[:newIdx]...) out = append(out, kept[:newIdx]...)
out = append(out, moved...) 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. // Drop the opaque placeholder rows kept for unmodeled anchor lines.
rules = f.compactNATRules(rules) rules = f.compactNATRules(rules)
// Merge families, then number the anchor's NAT rules as one ordered list so a // Report one rule per anchor row: a nat/rdr line written without `inet`/`inet6`
// collapsed pair leaves no gap; foreign NAT rules appended below keep Number 0. // matches both families and reads back as FamilyAny on its own. Number the
rules = mergeNATFamilies(rules) // anchor's NAT rules as one ordered list; foreign NAT rules appended below keep
// Number 0.
numberNATSequential(rules) numberNATSequential(rules)
rules = append(rules, f.listForeignNATRules(ctx)...) rules = append(rules, f.listForeignNATRules(ctx)...)
return rules, nil return rules, nil
@ -1542,6 +1560,9 @@ func (f *PF) MarshalNATRule(r *NATRule) (string, error) {
if err := r.validate(); err != nil { if err := r.validate(); err != nil {
return "", err 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 // 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 // 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. // 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) return f.loadAnchor(ctx, natRaw, filterRaw)
} }
// natAnchors is filterAnchors for NAT rules: it maps each logical (merged) NAT // natAnchors is filterAnchors for NAT rules: it maps each logical NAT rule to its
// rule to its physical row index, skipping opaque (nil) rows. // physical row index, skipping opaque (nil) rows.
func (f *PF) natAnchors(rules []*NATRule) []int { func (f *PF) natAnchors(rules []*NATRule) []int {
phys := make([]int, 0, len(rules)) anchors := make([]int, 0, len(rules))
modeled := make([]*NATRule, 0, len(rules))
for i, r := range rules { for i, r := range rules {
if r == nil { if r == nil {
continue continue
} }
phys = append(phys, i) anchors = append(anchors, i)
modeled = append(modeled, r)
}
anchors := mergedNATFamilyAnchors(modeled)
for k := range anchors {
anchors[k] = phys[anchors[k]]
} }
return anchors return anchors
} }
@ -1775,13 +1790,10 @@ func (f *PF) InsertNATRule(ctx context.Context, zoneName string, position int, r
if position <= 0 { if position <= 0 {
position = 1 position = 1
} }
// position is a merged Number: GetNATRules collapses IPv4/IPv6 pairs and numbers // position is a Number GetNATRules reported, which counts only the rules it can
// the result, while the anchor holds one physical row per rule. Map through the // model. rules is 1:1 with natRaw, and natAnchors skips any opaque (unmodeled)
// merged NAT anchors so a collapsed pair earlier in the list does not skew the // row so a foreign anchor line does not consume a logical position.
// placement (and does not split a family pair). rules is 1:1 with natRaw, and idx := logicalInsertIndex(f.natAnchors(rules), len(natRaw), position)
// natAnchors skips any opaque (unmodeled) row so it does not consume a
// logical position.
idx := mergedInsertIndex(f.natAnchors(rules), len(natRaw), position)
natRaw = append(natRaw[:idx], append([]string{line}, natRaw[idx:]...)...) natRaw = append(natRaw[:idx], append([]string{line}, natRaw[idx:]...)...)
// Preserve the filter rules that share the anchor. // 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 return err
} }
// Rebuild the NAT ruleset without the matching row(s). GetNATRules collapses an // Rebuild the NAT ruleset without the matching row(s). A FamilyAny target spans
// IPv4/IPv6 twin into one FamilyAny rule (mergeNATFamilies), so removing that // rows the anchor may hold separately (an IPv4 row and an IPv6 row added one at a
// read-back rule must clear both underlying anchor rows — mirror RemoveRule: // time), so removing it must clear all of them — mirror RemoveRule: match with
// match with EqualForRemoval so a concrete-family target still removes only its // EqualForRemoval so a concrete-family target still removes only its own family
// own family and never the twin's row. // and never the twin's row.
kept := make([]string, 0, len(natRaw)) kept := make([]string, 0, len(natRaw))
removed := false removed := false
for i, e := range rules { 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) 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 (`<table>`) 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. // GetDefaultPolicy is unsupported; pf exposes no default policy in this model.
func (f *PF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { func (f *PF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
return nil, unsupportedPolicy(f.Type()) return nil, unsupportedPolicy(f.Type())
@ -2040,6 +1982,76 @@ func (f *PF) RemoveAddressSetEntry(ctx context.Context, name, entry string) erro
return err 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 (`<table>`) 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. // Reload is a no-op; pf applies anchor changes immediately.
func (f *PF) Reload(ctx context.Context) error { func (f *PF) Reload(ctx context.Context) error {
return nil return nil
@ -2068,24 +2080,28 @@ func (f *PF) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule)
} }
for _, top := range rules { for _, top := range rules {
// A DirAny rule fans out into an inbound rule plus its swapped outbound rule. // A DirAny rule fans out into an inbound rule plus its swapped outbound rule,
for _, r := range expandDirections(top) { // and a TCPUDP rule into a tcp rule and a udp rule; pfctl stores each
line, err := f.MarshalRule(r) // direction/transport as its own row.
if err != nil { for _, dir := range expandDirections(top) {
return err for _, r := range expandProtocols(dir) {
} line, err := f.MarshalRule(r)
dup := false if err != nil {
for _, e := range existing { return err
if e != nil && e.Equal(r, true) {
dup = true
break
} }
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) return f.loadAnchor(ctx, natRaw, filterRaw)
@ -2106,13 +2122,17 @@ func (f *PF) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Ru
var filterRaw []string var filterRaw []string
for _, top := range rules { for _, top := range rules {
// A DirAny rule fans out into an inbound rule plus its swapped outbound rule. // A DirAny rule fans out into an inbound rule plus its swapped outbound rule,
for _, r := range expandDirections(top) { // and a TCPUDP rule into a tcp rule and a udp rule; pfctl stores each
line, err := f.MarshalRule(r) // direction/transport as its own row.
if err != nil { for _, dir := range expandDirections(top) {
return err 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) return f.loadAnchor(ctx, natRaw, filterRaw)

View file

@ -142,9 +142,9 @@ func TestPFAnchorPreservesUnmodeled(t *testing.T) {
} }
// TestPFReorderRowsKeepsOpaque verifies the move/remove row rebuild keeps an opaque // TestPFReorderRowsKeepsOpaque verifies the move/remove row rebuild keeps an opaque
// (nil) row in place and maps the merged target position to the correct physical // (nil) row in place and maps the target position to the correct physical index past
// index past it, so relocating a modeled rule never drops or displaces a foreign // it, so relocating a modeled rule never drops or displaces a foreign line sharing
// line sharing our anchor. // our anchor.
func TestPFReorderRowsKeepsOpaque(t *testing.T) { func TestPFReorderRowsKeepsOpaque(t *testing.T) {
fw := new(PF) fw := new(PF)
ruleA := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept} ruleA := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept}
@ -333,7 +333,6 @@ func TestPFFeatureRules(t *testing.T) {
// ErrUnsupportedForward sentinel. // ErrUnsupportedForward sentinel.
_, err = fw.MarshalRule(&Rule{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept}) _, err = fw.MarshalRule(&Rule{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept})
require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected") 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) { 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 // A pf rule written per family lives in two anchor rows. RemoveRule/MoveRule must
// rule (impliedFamily FamilyAny). RemoveRule/MoveRule must locate that read-back // locate both from a FamilyAny target with EqualForRemoval, not the family-strict
// rule with EqualForRemoval, not the family-strict Equal — which never matches // Equal — which matches neither, so the port stays open. Regression for the pf remove
// it, so the port stays open. Regression for the pf remove no-op on a merged rule. // no-op on a family-agnostic target.
func TestPFMergedFilterTwinIsRemovable(t *testing.T) { func TestPFFamilyAnyTargetMatchesBothTwins(t *testing.T) {
f := &PF{anchor: "go_firewall"} f := &PF{anchor: "go_firewall"}
v4, err := f.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22") v4, err := f.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22")
require.NoError(t, err) require.NoError(t, err)
v6, err := f.UnmarshalRule("pass in quick inet6 proto tcp from any to any port = 22") v6, err := f.UnmarshalRule("pass in quick inet6 proto tcp from any to any port = 22")
require.NoError(t, err) require.NoError(t, err)
merged := mergeFamiliesCopy([]*Rule{v4, v6}) // The two rows cover the family-agnostic rule between them.
require.Len(t, merged, 1, "the v4/v6 twin must collapse into one rule") target := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept, Direction: DirInput}
m := merged[0] require.True(t, target.CoveredBy([]*Rule{v4, v6}))
require.Equal(t, FamilyAny, m.impliedFamily())
// The old family-strict matcher could find neither physical row. // The family-strict matcher finds neither physical row.
require.False(t, m.Equal(v4, true)) require.False(t, target.Equal(v4, true))
require.False(t, m.Equal(v6, true)) require.False(t, target.Equal(v6, true))
// The new matcher (EqualForRemoval) finds both, so RemoveRule clears both anchor // EqualForRemoval finds both, so RemoveRule clears both anchor rows and MoveRule
// rows and MoveRule can locate the rule. // can locate the rule.
require.True(t, v4.EqualForRemoval(m, true)) require.True(t, v4.EqualForRemoval(target, true))
require.True(t, v6.EqualForRemoval(m, true)) require.True(t, v6.EqualForRemoval(target, true))
} }
// MoveRule must relocate BOTH physical rows of a merged IPv4/IPv6 pair, not just // MoveRule must relocate every physical row a target covers, not just the first.
// the first. mergeFamilies re-pairs rows on the lower physical index, so moving only // Moving only the first row of a v4/v6 pair leaves the twin at the earlier index —
// the first row of the pair leaves the twin at the earlier index — which then wins // which then wins on the next read, making a move to a LATER position a silent no-op.
// on the next read, making a move to a LATER position a silent no-op. reorderRows // reorderRows moves the covered rows as a block. Regression for the pf MoveRule
// moves the pair as a block. Regression for the pf MoveRule merged-pair defect. // family-pair defect.
func TestPFReorderRowsMergedPair(t *testing.T) { func TestPFReorderRowsFamilyPair(t *testing.T) {
fw := new(PF) fw := new(PF)
mk := func(fam Family, port uint16) *Rule { mk := func(fam Family, port uint16) *Rule {
return &Rule{Family: fam, Proto: TCP, Port: port, Action: Accept} 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). // 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)} rules := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(IPv4, 80), mk(IPv6, 80)}
raw := []string{"A_v4", "A_v6", "B_v4", "B_v6"} 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. // Move both A rows past B. Once A is pulled out, two rows remain, so position 3 is
out, moved := fw.reorderRows(rules, raw, mk(FamilyAny, 22), 2) // past the end and appends.
out, moved := fw.reorderRows(rules, raw, mk(FamilyAny, 22), 3)
require.True(t, moved) require.True(t, moved)
require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out, 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) out, moved = fw.reorderRows(rules, raw, mk(FamilyAny, 80), 1)
require.True(t, moved) require.True(t, moved)
require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out) 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. // 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.True(t, moved)
require.Equal(t, []string{"A_v6", "B_v4", "B_v6", "A_v4"}, out) 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) 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) // writeFileLines must preserve the original file's mode (not loosen it to 0644)
// and must not leave a fixed-name temp file behind. // and must not leave a fixed-name temp file behind.
func TestWriteFileLinesPreservesMode(t *testing.T) { func TestWriteFileLinesPreservesMode(t *testing.T) {

266
services.go Normal file
View file

@ -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
}

View file

@ -9,13 +9,9 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
dbus "github.com/coreos/go-systemd/dbus"
) )
const ( const (
// UFWType identifies the ufw backend.
UFWType = "ufw"
// UFWIPv4 is ufw's IPv4 user-rules file. // UFWIPv4 is ufw's IPv4 user-rules file.
UFWIPv4 = "/etc/ufw/user.rules" UFWIPv4 = "/etc/ufw/user.rules"
// UFWIPv6 is ufw's IPv6 user-rules file. // 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 := new(UFW)
ufw.rulePrefix = rulePrefix ufw.rulePrefix = rulePrefix
// Connect to systemd dbus interface. // Confirm ufw is enabled under whatever init system the host uses
conn, err := dbus.NewWithContext(ctx) // (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local).
if err != nil { if !serviceEnabled(ctx, "ufw") {
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" {
return nil, fmt.Errorf("the ufw service is not enabled on this server") 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]) 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") 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 { if err != nil {
return nil, err 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 return
} }
@ -484,13 +483,9 @@ func (f *UFW) ParseRules(filePath string, family Family) (rules []*Rule, err err
return rules, nil return rules, nil
} }
// GetRules reports it) to the 1-based position ufw's own numbered list uses for // GetRules returns the existing filter rules from the zone: ufw's own numbered
// `ufw insert`. GetRules merges IPv4/IPv6 tuple pairs, but ufw numbers every IPv4 // tuples from user.rules and user6.rules, then the raw iptables rules from the
// tuple then every IPv6 tuple without merging, so the two index spaces diverge // before.rules files, which carry the matches the tuple format cannot express.
// once a dual-family rule and a single-family rule coexist. The pre-merge tuple
// order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's native order, so
// the merged position's anchor row in that list is its native position. A position
// past the last logical rule maps past the native count, which ufw rejects and
func (f *UFW) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { func (f *UFW) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
// Parse IPv4 user rules. // Parse IPv4 user rules.
tupleRules, err := f.ParseRules(UFWIPv4, IPv4) 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...) rules = append(rules, iptablesRules...)
} }
// Merge rules across families, then renumber the ufw-list rules so a collapsed // Every row above is reported as ufw stores it. ufw keys its two families into
// v4/v6 pair leaves no gap. Only the numbered tuple rules (Number != 0) are // separate files and its raw entries into per-family before.rules, so a rule
// resequenced; before.rules entries kept Number 0 and stay outside the list. // spanning both families is two rows; only the ported `any` tuple carries both
rules = mergeFamilies(rules) // transports in one entry, and UnmarshalRule reads that back as TCPUDP on its own.
n := 0
for _, r := range rules {
if r.Number != 0 {
n++
r.Number = n
}
}
// Collapse each input/output twin into one DirAny rule after numbering, so the
// surviving tuple rows keep their list position.
rules = mergeDirections(rules)
return return
} }
@ -623,14 +608,35 @@ func (f *UFW) MarshalRule(r *Rule) (string, error) {
if r.Log && r.LogPrefix != "" { if r.Log && r.LogPrefix != "" {
return "", fmt.Errorf("ufw cannot set a custom log prefix in a tuple") 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 // 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 { if r.HasPortSet() && r.Proto != TCP && r.Proto != UDP {
return "", fmt.Errorf("ufw requires tcp or udp with multiple ports") 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 // 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 // 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 { if r.HasSourcePortSet() && r.Proto != TCP && r.Proto != UDP {
return "", fmt.Errorf("ufw requires tcp or udp for a source-port list or range") 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 // 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 // from clause) is present; a bare destination-port rule carries its protocol
// in the short form below. // in the short form below. A proto clause is emitted only for a concrete
if r.Proto != ProtocolAny && (dstAddr != "" || srcAddr != "") { // 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()) 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 // 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 == "" { if r.HasPorts() && dstAddr == "" && srcAddr == "" {
val := iptMultiportValue(r.PortSpecs()) val := iptMultiportValue(r.PortSpecs())
if r.Proto == ProtocolAny { if r.Proto == TCPUDP {
parts = append(parts, val) parts = append(parts, val)
} else { } else {
parts = append(parts, fmt.Sprintf("%s/%s", val, r.Proto.String())) 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 // 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 // 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 // line, coalesced on read and dropped together on remove. A removal clears every
// file was changed. // 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) { func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove bool) (bool, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@ -925,7 +937,11 @@ func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove
m.LogPrefix = pendingRule.LogPrefix m.LogPrefix = pendingRule.LogPrefix
logical = &m 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 removed = true
// Only drop the held LOG line when it was this rule's own LOG half // 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, // (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 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. // AddRule adds a filter rule to the zone.
func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error { 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 // 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 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 // ICMP, connection-state, logging and rate/connection-limit rules are not
// expressible through the ufw CLI, so they are written to the iptables-based // expressible through the ufw CLI, so they are written to the iptables-based
// before.rules files instead. // before.rules files instead.
@ -1116,33 +1170,39 @@ func (f *UFW) appendRule(ctx context.Context, r *Rule) error {
return err 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 // 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 // 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 // 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 // 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 // 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 // firewall, change enforcement. Every modeled tuple is its own rule, so with no
// plain merged index (position within the representable, merge-collapsed list). // un-representable rows this reduces to the plain physical position.
func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int { func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int {
var tuples []*Rule
var physPos []int var physPos []int
for i, r := range rows { for i, r := range rows {
if r != nil { if r != nil {
tuples = append(tuples, r)
physPos = append(physPos, i+1) physPos = append(physPos, i+1)
} }
} }
repIdx := mergedInsertIndex(mergedFamilyAnchors(tuples), len(tuples), position) if position < 1 {
if repIdx >= len(tuples) { position = 1
}
if position-1 >= len(physPos) {
// Past the last logical rule: point past the last physical tuple so ufw // Past the last logical rule: point past the last physical tuple so ufw
// rejects the position and InsertRule falls back to a plain append. // rejects the position and InsertRule falls back to a plain append.
return len(rows) + 1 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) { func (f *UFW) nativeInsertPosition(position int) (int, error) {
v4, err := f.parseTupleRows(UFWIPv4, IPv4) v4, err := f.parseTupleRows(UFWIPv4, IPv4)
if err != nil { if err != nil {
@ -1173,6 +1233,17 @@ func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r *
return nil 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) { if f.needsIPTablesRules(r) {
return f.editIPTablesRules(r, false) return f.editIPTablesRules(r, false)
} }
@ -1185,11 +1256,10 @@ func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r *
return err return err
} }
// position is a merged Number: GetRules collapses IPv4/IPv6 tuple pairs and // position is a Number GetRules reported, which counts only the tuples this
// numbers the result, while `ufw insert` counts ufw's own numbered list, which // backend models, while `ufw insert` counts ufw's own numbered list — which also
// lists every IPv4 tuple then every IPv6 tuple without merging. Map the merged // counts the route rules it does not model. Map to that native position so a
// position to that native position so a dual-family rule earlier in the list // route rule earlier in the list does not skew the insert.
// does not skew the insert (splitting a pair) when single-family rules coexist.
native, err := f.nativeInsertPosition(position) native, err := f.nativeInsertPosition(position)
if err != nil { if err != nil {
return err return err
@ -1212,6 +1282,50 @@ func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r *
return err 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. // RemoveRule removes a filter rule from the zone.
func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
// A DirAny target removes both its inbound and outbound tuple. // 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 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) { if f.needsIPTablesRules(r) {
return f.editIPTablesRules(r, true) return f.editIPTablesRules(r, true)
} }
rule, err := f.MarshalRule(r) // Protocol-axis removal. A TCPUDP rule is backed either by a single native
if err != nil { // `any`-proto tuple or by a separately-added tcp tuple + udp tuple, so resolve the
return err // actual backing before deleting.
} if onProtocolAxis(r.Proto) {
args := f.ruleArgs(r, []string{"delete"}, rule) native, err := f.storedNativeTCPUDP(r)
if _, err = runCommand(ctx, "ufw", args...); err != nil { if err != nil {
// Removing an already-absent rule is a no-op, matching every other backend. return err
// ufw reports the miss as "Could not delete non-existent rule". }
if strings.Contains(strings.ToLower(err.Error()), "could not delete") { 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 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 // 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 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. // policyKey is the /etc/default/ufw key for a direction's default policy.
func (f *UFW) policyKey(d Direction) string { func (f *UFW) policyKey(d Direction) string {
switch d { switch d {
@ -1544,6 +1602,96 @@ func (f *UFW) RemoveAddressSetEntry(ctx context.Context, name, entry string) err
return f.setHelper().RemoveAddressSetEntry(ctx, name, entry) 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 // 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 // ufw CLI apply immediately, but edits to those files only take effect after a
// reload. // reload.

View file

@ -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, 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") 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} plain := []*Rule{a, b}
require.Equal(t, 1, fw.nativeInsertPositionFromRows(plain, 1)) require.Equal(t, 1, fw.nativeInsertPositionFromRows(plain, 1))
require.Equal(t, 2, fw.nativeInsertPositionFromRows(plain, 2)) 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, 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") "unexpected source-port marshal")
// A single source port with an unspecified protocol is valid (ufw emits // A single source port needs a port-carrying protocol. TCPUDP (ufw's ported `any`,
// `proto all ... --sport`); only a source-port list or range needs tcp/udp. // tcp+udp) marshals to the native form with no proto clause; ProtocolAny (every
anySrc, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: ProtocolAny, SourcePort: 1234, Action: Accept}) // protocol) is now rejected, since ufw cannot match a port across every protocol. A
require.NoError(t, err, "a single source port with proto any must be accepted") // 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") require.Contains(t, anySrc, "port 1234")
_, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: ProtocolAny, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}) require.NotContains(t, anySrc, "proto", "a tcpudp rule omits the proto clause")
require.Error(t, err, "expected a source-port range without tcp/udp to be rejected") _, 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. // Test rules we typically set.
validRules := []string{ 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") 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 // 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{ unsupported := []*Rule{
{Proto: ICMP, Action: Accept}, {Proto: ICMP, Action: Accept},
{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}, {Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
{Proto: ProtocolAny, Ports: []PortRange{{Start: 80}, {Start: 443}}, 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 { for _, r := range unsupported {
_, err := fw.MarshalRule(r) _, 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") 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 // 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 // `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 // 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.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") 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))
}

View file

@ -11,8 +11,6 @@ import (
) )
const ( 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. // The IP protocol numbers for the transport/tunnel protocols the model adds.
// Windows filters by raw protocol number, so these map directly. // Windows filters by raw protocol number, so these map directly.
wfProtocolGRE = 47 wfProtocolGRE = 47
@ -449,17 +447,25 @@ func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err
rules = append(rules, r) rules = append(rules, r)
} }
// Collapse each inbound rule and its outbound twin into one DirAny rule. WFP // Every filter above is reported as WFP stores it. WFP has no per-rule family
// stores an inbound and an outbound filter as separate objects; the local/remote // selector, so a FamilyAny rule is one dual-family filter and reads back as
// swap the marshal path applies means a both-directions allow reads back as an // FamilyAny on its own. Its protocol field carries a single protocol number and
// input (source) rule plus an output (destination) rule, which merge here. // its direction field a single direction, so a TCPUDP rule is a tcp filter plus a
rules = mergeDirections(rules) // udp filter and a DirAny rule an inbound plus an outbound filter — each reported
// as its own rule.
return rules, nil return rules, nil
} }
// MarshallFWRule encodes a Rule as a Windows FWRule for the given zone. // MarshallFWRule encodes a Rule as a Windows FWRule for the given zone.
func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) { 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; // The Windows Firewall rule model has only inbound and outbound directions;
// forwarded (routed) traffic is handled out of band (RRAS/portproxy), so a // forwarded (routed) traffic is handled out of band (RRAS/portproxy), so a
// forward rule cannot be expressed here. // 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") 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 // A DirAny rule fans out into an inbound filter plus its role-swapped outbound
// filter; WFP stores each direction as its own rule object. // filter; WFP stores each direction as its own rule object.
if r.Direction == DirAny { if r.Direction == DirAny {
@ -738,6 +755,18 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return err 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. // A DirAny target removes both its inbound and its role-swapped outbound filter.
if r.Direction == DirAny { if r.Direction == DirAny {
for _, sub := range expandDirections(r) { 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()) 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. // GetDefaultPolicy is unsupported; the Windows firewall exposes no default policy in this model.
func (f *WF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { func (f *WF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
return nil, unsupportedPolicy(f.Type()) return nil, unsupportedPolicy(f.Type())
@ -892,6 +892,35 @@ func (f *WF) RemoveAddressSetEntry(ctx context.Context, name, entry string) erro
return unsupportedSet(f.Type()) 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. // Reload is a no-op; Windows Firewall applies rule changes immediately.
func (f *WF) Reload(ctx context.Context) error { func (f *WF) Reload(ctx context.Context) error {
return nil return nil

View file

@ -1,7 +1,6 @@
package firewall package firewall
import ( import (
"context"
"testing" "testing"
wapi "github.com/iamacarpet/go-win64api" wapi "github.com/iamacarpet/go-win64api"
@ -113,7 +112,62 @@ func TestWFProtocolAndComment(t *testing.T) {
// rejected with the ErrUnsupportedForward sentinel. // rejected with the ErrUnsupportedForward sentinel.
_, err = fw.MarshallFWRule("", &Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept}) _, err = fw.MarshallFWRule("", &Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept})
require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected") 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: // 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") 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 // decodeAddress must surface a Windows rule that carries a comma-separated
// address list (built-in rules commonly do) by decoding its first entry, rather // address list (built-in rules commonly do) by decoding its first entry, rather
// than erroring and letting UnmarshallFWRule drop the whole rule. // than erroring and letting UnmarshallFWRule drop the whole rule.