Advertise capability flags and harden backend semantics

- New Capabilities: PortPair, Negation, RejectAction,
  FamilyWithoutAddress, DenyActionFromConfig, advertised per backend.
- coversDirection isolates DirForward even when output is unowned;
  add splitNATDualRow so a concrete-family removal re-adds the opposite
  family's NAT translation.
- Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the
  "invalid" sentinel as input while JSON round-trips it.
- Sync counts additions on mid-batch failure and uses RuleBatcher.
- NewManager runs a probe loop joining each backend's reason for
  diagnosability; services.go drops "generated" from enabled, handles it
  on enable, clears start-limit-hit on restart, and matches rc.local by
  token.
- nftables: per-source connection limits (meter set), quoted-token
  parsing preserving log-prefix spacing, digit-led prefix sanitizing.
- apf/csf: deny-action-from-config with cached STOP settings, port lists
  and inexpressible shapes routed through the pre-hook, confKeyApplies
  guard against a missing config line.
- atomic config writes fsync before rename and resolve symlinks;
  readConfValue is last-assignment-wins; runCommand preserves the exit
  code through the wrapped error.
- Move coreos/go-systemd to the maintained v22 module directly.
This commit is contained in:
James Coleman 2026-07-13 17:50:43 -05:00
parent b4e54a66b6
commit 5095d90fa4
34 changed files with 4448 additions and 1459 deletions

View file

@ -36,7 +36,7 @@ CLI_BIN := $(BUILD_DIR)/go-firewall
CLI_SRC := $(shell find $(CLI_DIR) . -maxdepth 1 -name '*.go' -not -name '*_test.go') \ CLI_SRC := $(shell find $(CLI_DIR) . -maxdepth 1 -name '*.go' -not -name '*_test.go') \
$(CLI_DIR)/go.mod $(CLI_DIR)/go.sum go.mod go.sum $(CLI_DIR)/go.mod $(CLI_DIR)/go.sum go.mod go.sum
.PHONY: all lint test test-general test-integration For test-integration-linux test-integration-freebsd test-integration-windows cli install clean .PHONY: all lint test test-general test-integration test-integration-linux test-integration-freebsd test-integration-windows cli install clean
# Bare `make` builds the CLI. # Bare `make` builds the CLI.
.DEFAULT_GOAL := all .DEFAULT_GOAL := all

View file

@ -312,7 +312,7 @@ below still documents how each backend expresses these features.
| SCTP/GRE/ESP/AH | rich rule | yes | yes | yes | yes | yes | yes | by number (no SCTP port) | | SCTP/GRE/ESP/AH | rich rule | yes | yes | yes | yes | yes | yes | by number (no SCTP port) |
| Comment | no | yes | yes | yes | yes | yes | label | description | | Comment | no | yes | yes | yes | yes | yes | label | description |
| Port range | yes | yes | yes | yes | yes | yes | yes | yes | | Port range | yes | yes | yes | yes | yes | yes | yes | yes |
| Port list | no | yes | yes | ports config | yes | yes | yes | yes | | Port list | no | yes | adv rule / hook | via hook | yes | yes | yes | yes |
| Source port | yes | yes | adv rule | adv rule | yes | yes | yes | yes | | Source port | yes | yes | adv rule | adv rule | yes | yes | yes | yes |
| Connection state | no | yes | yes | yes | yes | yes | no (stateful) | no | | Connection state | no | yes | yes | yes | yes | yes | no (stateful) | no |
| Interface match | no (zone) | yes | yes | yes | yes | yes | yes | no | | Interface match | no (zone) | yes | yes | yes | yes | yes | yes | no |
@ -321,6 +321,13 @@ below still documents how each backend expresses these features.
| Connection limit | no | yes | per-port | per-port | yes | yes | per-source | no | | Connection limit | no | yes | per-port | per-port | yes | yes | per-source | no |
| NAT | fwd-port/masq | yes | dnat/redirect | dnat/snat/masq | yes | yes | rdr/nat (no redirect) | no | | NAT | fwd-port/masq | yes | dnat/redirect | dnat/snat/masq | yes | yes | rdr/nat (no redirect) | no |
`Capabilities().DenyActionFromConfig` (true for CSF and APF) flags a backend whose
native deny store carries no per-entry action: the tool applies the action its own
config names (`csf.conf` `DROP`, `conf.apf` `ALL_STOP`), so a deny added with the
config's action is stored natively, one with a differing action is expressed
through the backend's pre-hook, and `RemoveRule` clears a native deny entry
whatever action the removal target names.
## Default policy ## Default policy
`GetDefaultPolicy`/`SetDefaultPolicy` read and set the default action applied to `GetDefaultPolicy`/`SetDefaultPolicy` read and set the default action applied to
@ -355,7 +362,8 @@ construct:
| nftables | a set in the private `inet` table | | nftables | a set in the private `inet` table |
| firewalld | a firewalld ipset (D-Bus) | | firewalld | a firewalld ipset (D-Bus) |
| pf | a pf table | | pf | a pf table |
| CSF/APF/WFP | unsupported (`ErrUnsupportedSet`) | | CSF/APF | ipset commands in the managed pre-hook |
| WFP | unsupported (`ErrUnsupportedSet`) |
```go ```go
set := &firewall.AddressSet{Name: "blocklist", Family: firewall.IPv4, Type: firewall.SetHashNet} set := &firewall.AddressSet{Name: "blocklist", Family: firewall.IPv4, Type: firewall.SetHashNet}

View file

@ -45,6 +45,11 @@ type APF struct {
// concrete-IPv6 rule (see ipv6Unavailable) rather than write one apf will never // concrete-IPv6 rule (see ipv6Unavailable) rather than write one apf will never
// enforce. // enforce.
ipv6Enabled bool ipv6Enabled bool
// stopActions caches conf.apf's STOP settings, loaded on first use:
// deny_hosts commonly holds thousands of entries and each parsed line
// consults a STOP action, so per-line conf reads are avoided. The library
// never writes these keys.
stopActions map[string]Action
} }
// NewAPF verifies apf is installed and active, then returns a manager bound to rulePrefix. // NewAPF verifies apf is installed and active, then returns a manager bound to rulePrefix.
@ -87,13 +92,18 @@ func (f *APF) Capabilities() Capabilities {
return Capabilities{ return Capabilities{
Output: true, Output: true,
Forward: true, Forward: true,
// ICMPv6 mirrors ipv6Enabled: with conf.apf's USE_IPV6 off, apf never touches // ICMPv6 mirrors ipv6Enabled: with conf.apf's USE_IPV6 off, apf never
// ip6tables, so neither its native config nor the raw-iptables hook yields a // touches ip6tables — an IG_ICMPV6_TYPES entry yields no rule — so neither
// rule apf will keep in sync across a reload (see ipv6Unavailable). Confirmed // its native config nor the raw-iptables hook yields a rule apf will keep
// against a real apf install: ip6tables carries no rule for a type in // in sync across a reload (see ipv6Unavailable).
// IG_ICMPV6_TYPES when USE_IPV6=0.
ICMPv6: f.ipv6Enabled, ICMPv6: f.ipv6Enabled,
PortList: false, // PortList is honored through the pre-hook: apf's advanced rule holds a
// single port or one range, and a conf.apf CPORTS list stores each port
// as an independent token that reads back as its own rule, so every
// multi-port rule is injected as a single `-m multiport` iptables line
// instead (see needsHook).
PortList: true,
PortPair: true,
ConnState: true, ConnState: true,
InterfaceMatch: true, InterfaceMatch: true,
Logging: true, Logging: true,
@ -105,6 +115,12 @@ func (f *APF) Capabilities() Capabilities {
RuleCounters: false, RuleCounters: false,
AddressSets: true, AddressSets: true,
Comments: true, Comments: true,
Negation: true,
RejectAction: true,
FamilyWithoutAddress: true,
// A deny_hosts.rules entry stores no action; apf applies conf.apf's
// ALL_STOP action, so removal matches an entry whatever action is named.
DenyActionFromConfig: true,
} }
} }
@ -244,8 +260,7 @@ func (f *APF) parseStopAction(val string) Action {
// readStopAction reads the named STOP setting (ALL_STOP/TCP_STOP/UDP_STOP) // readStopAction reads the named STOP setting (ALL_STOP/TCP_STOP/UDP_STOP)
// from a conf.apf-format file, defaulting to the stock DROP when the key is // from a conf.apf-format file, defaulting to the stock DROP when the key is
// absent or the file cannot be read. path is a parameter (rather than always // absent or the file cannot be read.
// APFConf) so this can be exercised against a fixture file in tests.
func (f *APF) readStopAction(path, key string) Action { func (f *APF) readStopAction(path, key string) Action {
action := Drop action := Drop
fd, err := os.Open(path) fd, err := os.Open(path)
@ -292,21 +307,35 @@ func (f *APF) stopKey(proto Protocol) string {
} }
} }
// denyActionFor reads conf.apf's setting for the given protocol (see // confStopActions returns conf.apf's three STOP settings, read once per manager
// and cached (see the stopActions field).
func (f *APF) confStopActions() map[string]Action {
if f.stopActions == nil {
f.stopActions = map[string]Action{
"ALL_STOP": f.readStopAction(APFConf, "ALL_STOP"),
"TCP_STOP": f.readStopAction(APFConf, "TCP_STOP"),
"UDP_STOP": f.readStopAction(APFConf, "UDP_STOP"),
}
}
return f.stopActions
}
// denyActionFor returns the action apf applies to a deny of the given protocol,
// from conf.apf's STOP settings (see stopKey).
func (f *APF) denyActionFor(proto Protocol) Action { func (f *APF) denyActionFor(proto Protocol) Action {
stops := f.confStopActions()
// A TCPUDP deny is one protocol-less advanced line, which apf applies with // 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 // 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 // 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 // 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. // addRule route it to the hook, whose lines carry the action verbatim.
if proto == TCPUDP { if proto == TCPUDP {
tcp := f.readStopAction(APFConf, "TCP_STOP") if stops["UDP_STOP"] != stops["TCP_STOP"] {
if f.readStopAction(APFConf, "UDP_STOP") != tcp {
return ActionInvalid return ActionInvalid
} }
return tcp return stops["TCP_STOP"]
} }
return f.readStopAction(APFConf, f.stopKey(proto)) return stops[f.stopKey(proto)]
} }
// resolveAction resolves the action to stamp on a rule parsed from — or // resolveAction resolves the action to stamp on a rule parsed from — or
@ -828,17 +857,10 @@ func (f *APF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
} }
// Add or remove the rule's tokens from the comma list, preserving any others. // Add or remove the rule's tokens from the comma list, preserving any others.
// Three collections cooperate here: // want holds the canonical form of every token the rule contributes and
// - want: the canonical form of every token this rule contributes, so a // present the canonical form of every kept token, so removal recognizes a
// remove can recognize an existing token as "ours" regardless of // token however the file spells it and an add never appends a duplicate;
// how it was spelled in the file. // kept carries the surviving tokens in their original spelling.
// - present: the canonical form of every token we decide to keep, so the
// add pass can tell whether one of the rule's tokens is already
// in the list and must not be appended a second time.
// - kept: the tokens, in original spelling, that survive into the
// rewritten list. This is the actual output.
// want and present are keyed by canonical form (comparison identity); kept
// holds the verbatim tokens (what we write back).
want := make(map[string]bool, len(wantTokens)) want := make(map[string]bool, len(wantTokens))
for _, w := range wantTokens { for _, w := range wantTokens {
want[canon(w)] = true want[canon(w)] = true
@ -846,10 +868,9 @@ func (f *APF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
present := make(map[string]bool) present := make(map[string]bool)
var kept []string var kept []string
// Pass 1: walk the existing config list. Each token is either dropped (only // Pass 1: walk the existing config list, dropping a token only on a remove
// on a remove, and only when it is one of the rule's own tokens) or kept. // and only when it is one of the rule's own; surviving tokens keep their
// We keep the original spelling but track it by canonical form so pass 2 can // original spelling and are tracked by canonical form for the add pass.
// dedupe against it.
for _, tok := range strings.Split(val, ",") { for _, tok := range strings.Split(val, ",") {
tok = strings.TrimSpace(tok) tok = strings.TrimSpace(tok)
if tok == "" { if tok == "" {
@ -886,8 +907,39 @@ func (f *APF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
} }
// EditConf adds or removes a rule in conf.apf, rewriting the file in place. // EditConf adds or removes a rule in conf.apf, rewriting the file in place.
// confKeyApplies reports whether a conf.apf key is one the rule's EditRulePort
// edit lands in, mirroring EditRulePort's own routing guards. EditConf uses it
// to detect a config missing the rule's key line (an operator-stripped or older
// conf.apf), where an add would otherwise report success with nothing written.
func (f *APF) confKeyApplies(key string, r *Rule) bool {
switch key {
case "IG_TCP_CPORTS":
return r.ConnLimit == nil && !r.IsOutput() && coversProtocol(r.Proto, TCP)
case "IG_UDP_CPORTS":
return r.ConnLimit == nil && !r.IsOutput() && coversProtocol(r.Proto, UDP)
case "EG_TCP_CPORTS":
return r.ConnLimit == nil && r.IsOutput() && coversProtocol(r.Proto, TCP)
case "EG_UDP_CPORTS":
return r.ConnLimit == nil && r.IsOutput() && coversProtocol(r.Proto, UDP)
case "IG_ICMP_TYPES":
return r.ConnLimit == nil && !r.IsOutput() && r.Proto == ICMP
case "EG_ICMP_TYPES":
return r.ConnLimit == nil && r.IsOutput() && r.Proto == ICMP
case "IG_ICMPV6_TYPES":
return r.ConnLimit == nil && !r.IsOutput() && r.Proto == ICMPv6
case "EG_ICMPV6_TYPES":
return r.ConnLimit == nil && r.IsOutput() && r.Proto == ICMPv6
case "IG_TCP_CLIMIT":
return f.isConnLimitRule(r) && r.Proto == TCP
case "IG_UDP_CLIMIT":
return f.isConnLimitRule(r) && r.Proto == UDP
}
return false
}
func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error { func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
// For port only rules, open the standard config file. // Open the standard config file; EditRulePort rewrites the port, icmp-type
// and CLIMIT list lines the rule applies to.
fd, err := os.Open(APFConf) fd, err := os.Open(APFConf)
if err != nil { if err != nil {
return err return err
@ -902,6 +954,7 @@ func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
defer af.Abort() defer af.Abort()
// Parse config one line at a time, adding the port rule. // Parse config one line at a time, adding the port rule.
keyMatched := false
scanner := bufio.NewScanner(fd) scanner := bufio.NewScanner(fd)
for scanner.Scan() { for scanner.Scan() {
// Get the line. // Get the line.
@ -933,6 +986,9 @@ func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
val = trimQuotes(strings.TrimSpace(val)) val = trimQuotes(strings.TrimSpace(val))
// Parse rules. // Parse rules.
if f.confKeyApplies(key, r) {
keyMatched = true
}
orig = f.EditRulePort(orig, key, val, r, remove) orig = f.EditRulePort(orig, key, val, r, remove)
_, _ = fmt.Fprintln(af, orig) _, _ = fmt.Fprintln(af, orig)
} }
@ -944,6 +1000,13 @@ func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
return serr return serr
} }
// An add against a config missing the rule's key line would write nothing
// while reporting success — the rule's port or type would simply never
// open. Removal of a rule whose key line is absent is a plain no-op.
if !remove && !keyMatched {
return fmt.Errorf("conf.apf carries no config list for this rule")
}
// Move new file into place, preserving mode and ownership. // Move new file into place, preserving mode and ownership.
return af.Commit() return af.Commit()
} }
@ -1037,7 +1100,7 @@ func (f *APF) listRows(action Action, match *Rule) []listRow {
// A bare all-protocol host allow/deny: a single address matching every // A bare all-protocol host allow/deny: a single address matching every
// protocol. apf's trust files hold no other portless address shape — a // protocol. apf's trust files hold no other portless address shape — a
// concrete-protocol host or a source+destination pair — so AddRule diverts // concrete-protocol host or a source+destination pair — so AddRule diverts
// those to the raw-iptables hook (hostNeedsHook) and never reaches here with // those to the raw-iptables hook (shapeNeedsHook) and never reaches here with
// one. A direct caller of this exported writer that supplies such a shape gets // one. A direct caller of this exported writer that supplies such a shape gets
// a best-effort single-address write, not a guard. // a best-effort single-address write, not a guard.
addr := match.Source addr := match.Source
@ -1279,11 +1342,11 @@ func (f *APF) dualStackPortNeedsHook(r *Rule) bool {
// raw iptables rule because apf's native config cannot express it. It is the single // raw iptables rule because apf's native config cannot express it. It is the single
// gate between the hook path and apf's config files: everything it rejects (returns // gate between the hook path and apf's config files: everything it rejects (returns
// true) is written to the hook, everything it accepts (returns false) maps onto // true) is written to the hook, everything it accepts (returns false) maps onto
// conf.apf or the allow_hosts/deny_hosts trust files. The shared shapes // conf.apf or the allow_hosts/deny_hosts trust files. The shared predicates
// (ruleNeedsHook, bareHostOneWay, hostNeedsHook, advRuleNeedsHook) and the two apf // (ruleNeedsHook, shapeNeedsHook, bareHostOneWay) and the two apf shapes RemoveRule
// shapes RemoveRule reuses to route a split (dualStackPortNeedsHook, nativeICMPv6) // reuses to route a split (dualStackPortNeedsHook, nativeICMPv6) stay standalone;
// keep their own predicates; the apf-only, single-use port/source-port/connlimit/icmp // the apf-only, single-use port/connlimit/icmp tests are inlined here as their sole
// tests are inlined here as their sole caller. // caller.
func (f *APF) needsHook(r *Rule) bool { func (f *APF) needsHook(r *Rule) bool {
// Features apf's native config cannot model — connection state, per-rule // Features apf's native config cannot model — connection state, per-rule
// interface, logging, rate limiting, forward-chain routing, ICMPv6, or a // interface, logging, rate limiting, forward-chain routing, ICMPv6, or a
@ -1293,25 +1356,20 @@ func (f *APF) needsHook(r *Rule) bool {
if ruleNeedsHook(r) && !f.nativeICMPv6(r) { if ruleNeedsHook(r) && !f.nativeICMPv6(r) {
return true return true
} }
// A one-way bare host has no trust-file form (a plain line is bidirectional, an // A shape no native apf form holds — a one-way bare host, a source+destination
// advanced rule needs a port), and a concrete-protocol host or a source+ // pair, a concrete-protocol portless host, an advanced-line address/port-flow
// destination pair likewise has none (see hostNeedsHook); all go to the hook. // overflow, or a bare protocol match (see shapeNeedsHook) — goes to the hook. A
if bareHostOneWay(r) || hostNeedsHook(r) { // native address-less ICMP/ICMPv6 accept is excluded there and routed below.
if shapeNeedsHook(r) {
return true return true
} }
// A ported source+destination pair, a source port matched with a destination port, // A multi-port tcp/udp list apf's config cannot carry as one rule: its advanced
// and an address-less source-port match all overflow the advanced line's single // rule holds a single port or one underscore range, and a conf.apf CPORTS comma
// address and single port-flow field (see advRuleNeedsHook); iptables matches each // list stores each port as an independent token that reads back as its own rule,
// directly, so they go to the hook. // so an address-less multi-port accept would never round-trip whole. Every list
if advRuleNeedsHook(r) { // goes to the hook's iptables multiport match instead, which keeps it on one line.
return true
}
// A multi-port tcp/udp list apf's config cannot carry: its advanced rule holds a
// single port or one underscore range, and the only native multi-port shape is an
// address-less accept (isConfRule, carried by the IG_*_CPORTS comma lists);
// every other list goes to the hook's iptables multiport match.
if onProtocolAxis(r.Proto) && if onProtocolAxis(r.Proto) &&
(len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1) && !f.isConfRule(r) { (len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1) {
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
@ -1327,14 +1385,6 @@ func (f *APF) needsHook(r *Rule) bool {
if r.Proto == ICMP && !f.isConfRule(r) { if r.Proto == ICMP && !f.isConfRule(r) {
return true return true
} }
// A bare protocol match with no address and no port has no native apf construct —
// the trust files key on an address and conf.apf's lists on a port or icmp type —
// but iptables expresses it directly, so it goes to the hook (see
// bareProtoNeedsHook). A native address-less ICMP/ICMPv6 accept is excluded there;
// connection limits and every other addressed/ported shape are routed above.
if bareProtoNeedsHook(r) {
return true
}
// A single-family bare tcp/udp port accept: apf's CPORTS lists are dual-stack, so // A single-family bare tcp/udp port accept: apf's CPORTS lists are dual-stack, so
// only a FamilyAny port is native; a single-family one is written per-family // only a FamilyAny port is native; a single-family one is written per-family
// through the hook (see dualStackPortNeedsHook, which RemoveRule also uses). // through the hook (see dualStackPortNeedsHook, which RemoveRule also uses).
@ -1466,6 +1516,13 @@ func (f *APF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule
return err return err
} }
if s := splitDualRowDirection(e, r); s != nil { if s := splitDualRowDirection(e, r); s != nil {
// An IPv6 plain line left over from when USE_IPV6 was on is not
// enforced with it off, and a hook-injected ip6tables line would
// outlive its own removal (see ipv6Unavailable); there is no coverage
// to preserve, so no v6 survivor is written.
if s.impliedFamily() == IPv6 && !f.ipv6Enabled {
return nil
}
changed, err := f.hook().edit(s, false) changed, err := f.hook().edit(s, false)
f.ConfigChanged = f.ConfigChanged || changed f.ConfigChanged = f.ConfigChanged || changed
return err return err
@ -1540,6 +1597,13 @@ func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error {
} }
surviving := *r surviving := *r
surviving.Family = oppositeFamily(r.impliedFamily()) surviving.Family = oppositeFamily(r.impliedFamily())
// With USE_IPV6 off apf never enforced the entry's IPv6 half, and a
// hook-injected ip6tables line is re-appended on every reload and
// outlives its own removal (see ipv6Unavailable), so there is no
// coverage to preserve and no v6 survivor to write.
if surviving.Family == IPv6 && !f.ipv6Enabled {
return nil
}
changed, err := f.hook().edit(&surviving, false) changed, err := f.hook().edit(&surviving, false)
f.ConfigChanged = f.ConfigChanged || changed f.ConfigChanged = f.ConfigChanged || changed
return err return err
@ -1556,7 +1620,8 @@ func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error {
// both. A read cannot tell which, so remove the rule from both backings — EditConf // both. A read cannot tell which, so remove the rule from both backings — EditConf
// drops it from the CPORTS list and the hook edit drops both per-family rows, and // drops it from the CPORTS list and the hook edit drops both per-family rows, and
// each no-ops when the rule is absent — clearing every cell the target covers, // each no-ops when the rule is absent — clearing every cell the target covers,
// wherever it lives. Unlike the single-family // wherever it lives. Unlike the single-family path there is no surviving family
// to re-express, so no split is needed.
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
@ -1579,6 +1644,14 @@ func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return nil return nil
} }
// Validate the shape before the hook sweep below: an iptables-inexpressible
// rule (a port on ProtocolAny) exists nowhere apf can hold it, and letting it
// fail inside the hook marshal would return the bare error without the
// sentinel AddRule attaches to the same shape.
if err := iptablesRuleValid(r); err != nil {
return fmt.Errorf("%v: %w", err, ErrUnsupported)
}
// Clear any hook copy of the rule first, no matter how apf stores it. A rule apf // Clear any hook copy of the rule first, no matter how apf stores it. A rule apf
// carries only in the hook (stateful/interface/logged/rate-limited/icmpv6/ // carries only in the hook (stateful/interface/logged/rate-limited/icmpv6/
// hook-only-proto) lives nowhere else, so this is its entire removal; a natively- // hook-only-proto) lives nowhere else, so this is its entire removal; a natively-
@ -1627,9 +1700,6 @@ func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
if f.needsHook(r) { if f.needsHook(r) {
return nil return nil
} }
if err := iptablesRuleValid(r); err != nil {
return fmt.Errorf("%v: %w", err, ErrUnsupported)
}
// A native connection-limit rule maps onto conf.apf's IG_*_CLIMIT lists. // A native connection-limit rule maps onto conf.apf's IG_*_CLIMIT lists.
if r.ConnLimit != nil { if r.ConnLimit != nil {
@ -1719,20 +1789,6 @@ func (f *APF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, err
return rules, nil return rules, nil
} }
// natFamilies lists the address families a NAT rule is written for: a rule
// pinned to a family touches only that command; a family-agnostic rule (e.g. a
// portless masquerade) is written for both v4 and v6.
func (f *APF) natFamilies(r *NATRule) []Family {
switch r.impliedFamily() {
case IPv4:
return []Family{IPv4}
case IPv6:
return []Family{IPv6}
default:
return []Family{IPv4, IPv6}
}
}
// natFile returns the routing file a NAT rule belongs in: source NAT is // natFile returns the routing file a NAT rule belongs in: source NAT is
// applied in POSTROUTING (postroute.rules), destination NAT in PREROUTING // applied in POSTROUTING (postroute.rules), destination NAT in PREROUTING
// (preroute.rules). // (preroute.rules).
@ -1743,14 +1799,6 @@ func (f *APF) natFile(r *NATRule) string {
return APFPreroute return APFPreroute
} }
// natCommand returns the iptables command name for a family.
func (f *APF) natCommand(fam Family) string {
if fam == IPv6 {
return "ip6tables"
}
return "iptables"
}
// natLine encodes a NAT rule as a raw iptables/ip6tables nat-table command // natLine encodes a NAT rule as a raw iptables/ip6tables nat-table command
// line for the given family, the form apf's shell-sourced routing files expect. // line for the given family, the form apf's shell-sourced routing files expect.
func (f *APF) natLine(r *NATRule, fam Family) (string, error) { func (f *APF) natLine(r *NATRule, fam Family) (string, error) {
@ -1761,7 +1809,7 @@ func (f *APF) natLine(r *NATRule, fam Family) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
return f.natCommand(fam) + " -t nat " + spec, nil return hookCommand(fam) + " -t nat " + spec, nil
} }
// editNATFile adds or removes a NAT rule's command line(s) in a routing file. // editNATFile adds or removes a NAT rule's command line(s) in a routing file.
@ -1770,9 +1818,15 @@ func (f *APF) natLine(r *NATRule, fam Family) (string, error) {
func (f *APF) editNATFile(r *NATRule, remove bool) error { func (f *APF) editNATFile(r *NATRule, remove bool) error {
path := f.natFile(r) path := f.natFile(r)
// The line(s) this rule contributes, one per family it targets. // The line(s) this rule contributes, one per family it targets. An add
// writes only the families apf enforces; a removal sweeps the full set
// (natRuleFamilies/natWriteFamilies, shared with the csf hook's NAT lines).
fams := natRuleFamilies(r)
if !remove {
fams = natWriteFamilies(f.ipv6Enabled, r)
}
want := make(map[string]bool) want := make(map[string]bool)
for _, fam := range f.natFamilies(r) { for _, fam := range fams {
line, err := f.natLine(r, fam) line, err := f.natLine(r, fam)
if err != nil { if err != nil {
return err return err
@ -1875,6 +1929,13 @@ func (f *APF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error
if err := r.validate(); err != nil { if err := r.validate(); err != nil {
return err return err
} }
// A concrete-IPv6 translation cannot be kept in sync with USE_IPV6 off: apf
// sources the routing files on every (re)load but never flushes the v6 nat
// table, so the injected line re-appends each reload and outlives its own
// removal — the NAT analog of ipv6Unavailable.
if !f.ipv6Enabled && r.impliedFamily() == IPv6 {
return fmt.Errorf("apf cannot manage an IPv6 nat rule with USE_IPV6 disabled: %w", ErrUnsupportedNAT)
}
return f.editNATFile(r, false) return f.editNATFile(r, false)
} }
@ -1933,6 +1994,7 @@ func (f *APF) AddAddressSet(ctx context.Context, set *AddressSet) error {
} }
// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a // RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a
// hook rule still references the set; removing an absent set is a no-op.
func (f *APF) RemoveAddressSet(ctx context.Context, name string) error { func (f *APF) RemoveAddressSet(ctx context.Context, name string) error {
changed, err := f.hook().editAddressSet(&AddressSet{Name: name}, true) changed, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
f.ConfigChanged = f.ConfigChanged || changed f.ConfigChanged = f.ConfigChanged || changed
@ -2023,12 +2085,15 @@ func (f *APF) Restore(ctx context.Context, zoneName string, backup *Backup) erro
// 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
// only restart when a mutation actually changed apf's config files. // only restart when a mutation actually changed apf's config files. A
// successful restart consumes the flag; otherwise a second Reload with no
// intervening mutation would restart apf again for nothing.
if f.ConfigChanged { if f.ConfigChanged {
_, err := runCommand(ctx, "/etc/apf/apf", "--restart") _, err := runCommand(ctx, "/etc/apf/apf", "--restart")
if err != nil { if err != nil {
return err return err
} }
f.ConfigChanged = false
} }
return nil return nil
} }

View file

@ -201,7 +201,7 @@ func TestAPFNATRoundTrip(t *testing.T) {
{Kind: Masquerade, Family: IPv4, Interface: "eth1"}, {Kind: Masquerade, Family: IPv4, Interface: "eth1"},
} }
for _, orig := range cases { for _, orig := range cases {
for _, fam := range fw.natFamilies(orig) { for _, fam := range natRuleFamilies(orig) {
line, err := fw.natLine(orig, fam) line, err := fw.natLine(orig, fam)
require.NoError(t, err, "marshal %+v", *orig) require.NoError(t, err, "marshal %+v", *orig)
got, ok := fw.parseNATLine(line) got, ok := fw.parseNATLine(line)
@ -593,7 +593,7 @@ func TestAPFDropRuleRemovable(t *testing.T) {
// A ported apf advanced rule that matches both a source and a destination address // A ported apf advanced rule that matches both a source and a destination address
// cannot be expressed (apf's advanced rule holds a single address field), so AddRule // cannot be expressed (apf's advanced rule holds a single address field), so AddRule
// routes it to the raw-iptables hook rather than silently dropping the destination. // routes it to the raw-iptables hook rather than silently dropping the destination.
// The portless bare form takes the same path one predicate earlier (hostNeedsHook). // The portless bare form takes the same path (shapeNeedsHook routes both).
func TestAPFDualAddressRouted(t *testing.T) { func TestAPFDualAddressRouted(t *testing.T) {
fw := new(APF) fw := new(APF)
require.True(t, fw.needsHook(&Rule{Proto: TCP, Port: 22, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}), require.True(t, fw.needsHook(&Rule{Proto: TCP, Port: 22, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}),
@ -741,7 +741,7 @@ func TestAPFPortOnlyDenyHealsAndDoesNotDuplicate(t *testing.T) {
// A bare all-protocol host rule (address, no port) is the one portless address // A bare all-protocol host rule (address, no port) is the one portless address
// shape apf's trust files express, written as the plain address line. The // shape apf's trust files express, written as the plain address line. The
// inexpressible shapes — a concrete-protocol host or a source+destination pair — // inexpressible shapes — a concrete-protocol host or a source+destination pair —
// are diverted to the hook by AddRule (hostNeedsHook) and never reach this // are diverted to the hook by AddRule (shapeNeedsHook) and never reach this
// writer, so only the legitimate write is exercised here. // writer, so only the legitimate write is exercised here.
func TestAPFBareHostWritten(t *testing.T) { func TestAPFBareHostWritten(t *testing.T) {
fw := new(APF) fw := new(APF)
@ -757,8 +757,9 @@ func TestAPFBareHostWritten(t *testing.T) {
// needsHook selects the multi-port lists apf's config cannot express, so // needsHook selects the multi-port lists apf's config cannot express, so
// AddRule diverts them to the hook. A single port or one range is a valid apf // AddRule diverts them to the hook. A single port or one range is a valid apf
// port token (stays native); an address-less tcp/udp accept list lives in // port token (stays native); an address-less tcp/udp accept list is hooked too,
// conf.apf's CPORTS lists; a non-tcp/udp match is left to iptablesRuleValid. // since a CPORTS entry reads back one rule per token and would never round-trip
// as the added list; a non-tcp/udp match is left to iptablesRuleValid.
func TestAPFPortNeedsHook(t *testing.T) { func TestAPFPortNeedsHook(t *testing.T) {
fw := new(APF) fw := new(APF)
list := []PortRange{{Start: 1000, End: 1000}, {Start: 2000, End: 2000}} list := []PortRange{{Start: 1000, End: 1000}, {Start: 2000, End: 2000}}
@ -770,7 +771,7 @@ func TestAPFPortNeedsHook(t *testing.T) {
{"multi-port deny", &Rule{Proto: TCP, Ports: list, Action: Reject}, true}, {"multi-port deny", &Rule{Proto: TCP, Ports: list, Action: Reject}, true},
{"multi-port host accept", &Rule{Proto: TCP, Ports: list, Source: "1.2.3.4", Action: Accept}, true}, {"multi-port host accept", &Rule{Proto: TCP, Ports: list, Source: "1.2.3.4", Action: Accept}, true},
{"multi-source-port host", &Rule{Proto: UDP, SourcePorts: list, Source: "1.2.3.4", Action: Accept}, true}, {"multi-source-port host", &Rule{Proto: UDP, SourcePorts: list, Source: "1.2.3.4", Action: Accept}, true},
{"address-less accept list", &Rule{Proto: TCP, Ports: list, Action: Accept}, false}, {"address-less accept list", &Rule{Proto: TCP, Ports: list, Action: Accept}, true},
{"single port deny", &Rule{Proto: TCP, Port: 1000, Action: Reject}, false}, {"single port deny", &Rule{Proto: TCP, Port: 1000, Action: Reject}, false},
{"single range host", &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Source: "1.2.3.4", Action: Accept}, false}, {"single range host", &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Source: "1.2.3.4", Action: Accept}, false},
{"multi-port any-proto", &Rule{Ports: list, Action: Reject}, false}, {"multi-port any-proto", &Rule{Ports: list, Action: Reject}, false},
@ -780,6 +781,19 @@ func TestAPFPortNeedsHook(t *testing.T) {
} }
} }
// TestAPFMultiPortRemovalSweepsCPortsTokens covers removal of a multi-port
// accept against a conf.apf CPORTS list: the rule lives in the hook now, but an
// earlier per-port add (or a manual edit) may hold the same ports as list
// tokens, so removeFamilyAnyPort's EditConf sweep must strip exactly the
// target's tokens and no others.
func TestAPFMultiPortRemovalSweepsCPortsTokens(t *testing.T) {
fw := new(APF)
target := &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
require.Equal(t, `IG_TCP_CPORTS="22"`,
fw.EditRulePort(`IG_TCP_CPORTS="22,80,443"`, "IG_TCP_CPORTS", "22,80,443", target, true),
"a multi-port removal must strip each of its own port tokens and keep the rest")
}
// APF EditIPList must write the missing IPv6 line when adding the IPv6 twin of an // APF EditIPList must write the missing IPv6 line when adding the IPv6 twin of an
// existing IPv4 port-only deny; the family-agnostic EqualBase check previously // existing IPv4 port-only deny; the family-agnostic EqualBase check previously
// treated the IPv4 line as covering IPv6 and wrote nothing, leaving IPv6 open. // treated the IPv4 line as covering IPv6 and wrote nothing, leaving IPv6 open.
@ -972,17 +986,17 @@ func TestAPFTCPUDPRouting(t *testing.T) {
"a dual-stack tcpudp port accept lives in the CPORTS lists") "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}), 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") "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. // An address-less multi-port accept is hooked: a CPORTS entry reads back one
// The same ports against an address are not — apf's advanced rule holds one port. // rule per token, so the hook's multiport line is the only round-tripping form.
require.False(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}), require.True(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") "a multi-port list must route to the hook's multiport match")
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, 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}), Source: "192.0.2.1", Action: Accept}),
"apf's advanced rule carries no port list") "apf's advanced rule carries no port list")
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, SourcePort: 80, Action: Accept}), require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, SourcePort: 80, Action: Accept}),
"an address-less source-port match has no advanced-rule form") "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). // 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})) require.True(t, shapeNeedsHook(&Rule{Proto: TCPUDP, Source: "192.0.2.1", Action: Accept}))
} }
// With conf.apf's USE_IPV6 off, apf installs no IPv6 rule from its config, so a // With conf.apf's USE_IPV6 off, apf installs no IPv6 rule from its config, so a
@ -1023,3 +1037,52 @@ func TestAPFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
require.NotContains(t, string(data), "d=80", require.NotContains(t, string(data), "d=80",
"removal must sweep the stale IPv6 line even with IPv6 off") "removal must sweep the stale IPv6 line even with IPv6 off")
} }
// With USE_IPV6 off a family-agnostic NAT rule is written for IPv4 only and a
// concrete-IPv6 one is rejected outright; removal still sweeps both families so
// a stale v6 line does not survive an IPv6 switch-off.
func TestAPFNATIPv6Gating(t *testing.T) {
off := new(APF)
masq := &NATRule{Kind: Masquerade, Interface: "eth0"}
require.Equal(t, []Family{IPv4}, natWriteFamilies(off.ipv6Enabled, masq),
"a family-agnostic write must narrow to IPv4 while IPv6 is off")
require.Equal(t, []Family{IPv4, IPv6}, natRuleFamilies(masq),
"removal still sweeps both families")
err := off.AddNATRule(context.Background(), "", &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 8080, ToAddress: "2001:db8::5"})
require.ErrorIs(t, err, ErrUnsupportedNAT, "a concrete-IPv6 nat add must be rejected while IPv6 is off")
on := &APF{ipv6Enabled: true}
require.Equal(t, []Family{IPv4, IPv6}, natWriteFamilies(on.ipv6Enabled, masq))
}
// RemoveRule of an iptables-inexpressible shape must reject with the sentinel
// before sweeping the hook, mirroring AddRule.
func TestAPFRemoveRuleUnsupportedShapeSentinel(t *testing.T) {
fw := new(APF)
err := fw.RemoveRule(context.Background(), "", &Rule{Proto: ProtocolAny, Port: 80, Action: Accept})
require.ErrorIs(t, err, ErrUnsupported)
}
// confKeyApplies mirrors EditRulePort's routing guards; EditConf keys its
// missing-config-line detection on it.
func TestAPFConfKeyApplies(t *testing.T) {
fw := new(APF)
port := &Rule{Proto: TCP, Port: 80, Action: Accept}
require.True(t, fw.confKeyApplies("IG_TCP_CPORTS", port))
require.False(t, fw.confKeyApplies("IG_UDP_CPORTS", port))
require.False(t, fw.confKeyApplies("EG_TCP_CPORTS", port))
both := &Rule{Proto: TCPUDP, Port: 53, Action: Accept}
require.True(t, fw.confKeyApplies("IG_TCP_CPORTS", both))
require.True(t, fw.confKeyApplies("IG_UDP_CPORTS", both))
icmp6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}
require.True(t, fw.confKeyApplies("IG_ICMPV6_TYPES", icmp6))
require.False(t, fw.confKeyApplies("IG_ICMP_TYPES", icmp6))
climit := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}
require.True(t, fw.confKeyApplies("IG_TCP_CLIMIT", climit))
require.False(t, fw.confKeyApplies("IG_TCP_CPORTS", climit),
"a connlimit rule must never touch the accept port lists")
}

View file

@ -112,9 +112,16 @@ func (u *RateUnit) UnmarshalJSON(data []byte) error {
// MarshalJSON renders the NAT kind as its stable name (e.g. "dnat"). // MarshalJSON renders the NAT kind as its stable name (e.g. "dnat").
func (k NATKind) MarshalJSON() ([]byte, error) { return marshalEnum(k, NATKind.String) } func (k NATKind) MarshalJSON() ([]byte, error) { return marshalEnum(k, NATKind.String) }
// UnmarshalJSON parses a NAT kind from its stable name. // UnmarshalJSON parses a NAT kind from its stable name. The sentinel "invalid"
// (NATInvalid) is accepted on the wire for round-trip fidelity even though
// ParseNATKind rejects it as caller input, mirroring Action.
func (k *NATKind) UnmarshalJSON(data []byte) error { func (k *NATKind) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, ParseNATKind) v, err := unmarshalEnum(data, func(s string) (NATKind, error) {
if strings.EqualFold(strings.TrimSpace(s), "invalid") {
return NATInvalid, nil
}
return ParseNATKind(s)
})
if err != nil { if err != nil {
return err return err
} }
@ -158,11 +165,15 @@ func captureBackupState(ctx context.Context, mgr Manager, zoneName string, b *Ba
if caps.DefaultPolicy { if caps.DefaultPolicy {
// Best-effort: a backend that cannot report a single coherent default policy // Best-effort: a backend that cannot report a single coherent default policy
// (iptables with out-of-band divergent IPv4/IPv6 chain policies) captures none // (iptables with out-of-band divergent IPv4/IPv6 chain policies) captures none
// rather than failing the whole backup. A genuine read error would already have // rather than failing the whole backup, and Restore then leaves the policy as
// surfaced from the GetRules call the Backup makes before this, which reads the // it finds it. A cancelled context is not that case — it would silently
// same source; leaving the field nil then makes Restore leave the policy as it // produce a snapshot missing the policy a default-drop host depends on — so
// finds it. // it still fails the backup.
if policy, err := mgr.GetDefaultPolicy(ctx, zoneName); err == nil { policy, err := mgr.GetDefaultPolicy(ctx, zoneName)
if err != nil && ctx.Err() != nil {
return err
}
if err == nil {
b.DefaultPolicy = policy b.DefaultPolicy = policy
} }
} }
@ -185,7 +196,7 @@ func captureBackupState(ctx context.Context, mgr Manager, zoneName string, b *Ba
// AddAddressSet is a no-op on an existing set and so would not reconcile its // AddAddressSet is a no-op on an existing set and so would not reconcile its
// entries. A caller whose old rules are still loaded when sets are recreated // entries. A caller whose old rules are still loaded when sets are recreated
// (a tag/rewrite backend) passes false and relies on AddAddressSet's own // (a tag/rewrite backend) passes false and relies on AddAddressSet's own
// idempotent create-or-repopulate (ipset -exist, pfctl -T add). // idempotent create-or-reconcile (ipset -exist, pfctl -T replace).
func restoreBackupSets(ctx context.Context, mgr Manager, b *Backup, cleanFirst bool) error { func restoreBackupSets(ctx context.Context, mgr Manager, b *Backup, cleanFirst bool) error {
if b == nil || !mgr.Capabilities().AddressSets { if b == nil || !mgr.Capabilities().AddressSets {
return nil return nil

View file

@ -10,7 +10,7 @@ require (
require ( require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/cabbie v1.0.2 // indirect github.com/google/cabbie v1.0.2 // indirect
@ -27,8 +27,3 @@ require (
) )
replace git.gec.im/GRMrGecko/go-firewall => ../.. replace git.gec.im/GRMrGecko/go-firewall => ../..
// Mirrors the library's own replace: the old coreos/go-systemd import path is
// redirected to the maintained v22 module (the v0 release does not compile
// against modern godbus). Kept here so the CLI is self-contained when built.
replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0

View file

@ -29,7 +29,12 @@ type atomicFile struct {
// to the returned handle, then calls Commit to install it or Abort to discard // to the returned handle, then calls Commit to install it or Abort to discard
// it. Capturing the destination's mode and ownership here means a later // it. Capturing the destination's mode and ownership here means a later
// scan-and-rewrite of the original still commits with the original's metadata. // scan-and-rewrite of the original still commits with the original's metadata.
// A symlinked destination is resolved so the rename replaces the link's target
// rather than turning the link itself into a regular file.
func newAtomicFile(dst string, defaultMode os.FileMode) (*atomicFile, error) { func newAtomicFile(dst string, defaultMode os.FileMode) (*atomicFile, error) {
if resolved, err := filepath.EvalSymlinks(dst); err == nil {
dst = resolved
}
mode := defaultMode mode := defaultMode
var uid, gid int var uid, gid int
var chown bool var chown bool
@ -62,15 +67,21 @@ func (a *atomicFile) Commit() error {
} }
// Apply mode and ownership through the fd so the staged file is never // Apply mode and ownership through the fd so the staged file is never
// briefly installed with the wrong permissions. // briefly installed with the wrong permissions.
if err := a.File.Chmod(a.mode); err != nil { if err := a.Chmod(a.mode); err != nil {
return a.fail(err) return a.fail(err)
} }
if a.chown { if a.chown {
if err := a.File.Chown(a.uid, a.gid); err != nil && !errors.Is(err, os.ErrPermission) { if err := a.Chown(a.uid, a.gid); err != nil && !errors.Is(err, os.ErrPermission) {
return a.fail(err) return a.fail(err)
} }
} }
if err := a.File.Close(); err != nil { // Sync before the rename: these files are reboot-persistence-critical
// (csf.conf, iptables save files), and a crash could otherwise reorder the
// rename ahead of the data reaching disk, installing a truncated config.
if err := a.Sync(); err != nil {
return a.fail(err)
}
if err := a.Close(); err != nil {
return a.fail(err) return a.fail(err)
} }
// Install the staged file. // Install the staged file.
@ -89,14 +100,14 @@ func (a *atomicFile) Abort() {
if a.done { if a.done {
return return
} }
_ = a.File.Close() _ = a.Close()
_ = os.Remove(a.tmp) _ = os.Remove(a.tmp)
a.done = true a.done = true
} }
// fail closes and removes the temp file, then returns the triggering error. // fail closes and removes the temp file, then returns the triggering error.
func (a *atomicFile) fail(err error) error { func (a *atomicFile) fail(err error) error {
_ = a.File.Close() _ = a.Close()
_ = os.Remove(a.tmp) _ = os.Remove(a.tmp)
a.done = true a.done = true
return err return err

View file

@ -155,3 +155,28 @@ func TestAtomicFileStagesInDestinationDir(t *testing.T) {
require.True(t, strings.HasPrefix(filepath.Base(af.tmp), "conf.tmp.")) require.True(t, strings.HasPrefix(filepath.Base(af.tmp), "conf.tmp."))
af.Abort() af.Abort()
} }
// readConfValue must not truncate a quoted value containing '#', and the last
// assignment of a key wins, matching the shell that sources these files.
func TestReadConfValueQuotingAndLastAssignment(t *testing.T) {
path := filepath.Join(t.TempDir(), "conf")
body := `
# A comment line.
PREFIX = "pre#fix" # trailing comment
USE_IPV6 = "0"
USE_IPV6 = "1"
`
require.NoError(t, os.WriteFile(path, []byte(body), 0644))
v, err := readConfValue(path, "PREFIX")
require.NoError(t, err)
require.Equal(t, "pre#fix", v, "a '#' inside quotes is part of the value")
v, err = readConfValue(path, "USE_IPV6")
require.NoError(t, err)
require.Equal(t, "1", v, "the last assignment wins")
v, err = readConfValue(path, "MISSING")
require.NoError(t, err)
require.Equal(t, "", v)
}

View file

@ -66,7 +66,7 @@ func NewCSF(ctx context.Context, rulePrefix string) (*CSF, error) {
} }
} }
// Confirm its not disabled. // Confirm it is not disabled.
if _, err := os.Stat("/etc/csf/csf.disable"); err == nil { if _, err := os.Stat("/etc/csf/csf.disable"); err == nil {
return nil, fmt.Errorf("csf is currently disabled") return nil, fmt.Errorf("csf is currently disabled")
} }
@ -78,7 +78,6 @@ func NewCSF(ctx context.Context, rulePrefix string) (*CSF, error) {
} }
csf.ipv6Enabled = useIPv6 == "1" csf.ipv6Enabled = useIPv6 == "1"
// Return the new csf object.
return csf, nil return csf, nil
} }
@ -96,12 +95,14 @@ func (f *CSF) Capabilities() Capabilities {
// ip6tables, so neither its native config nor the raw-iptables hook yields a // ip6tables, so neither its native config nor the raw-iptables hook yields a
// rule csf will keep in sync across a reload (see ipv6Unavailable). // rule csf will keep in sync across a reload (see ipv6Unavailable).
ICMPv6: f.ipv6Enabled, ICMPv6: f.ipv6Enabled,
// A csf.conf port list (TCP_IN="80,443,...") stores each port independently // PortList is honored through the pre-hook: a csf.conf port list
// and reads back as one rule per port, so a discrete multi-port rule does // (TCP_IN="80,443,...") stores each port independently and reads back as
// not round-trip as a single rule (a range, kept as one token, does). Report // one rule per port, so an address-less multi-port accept is injected as a
// PortList as unsupported to reflect that; callers open several ports by // single `-m multiport` iptables line instead (see multiPortConfAccept).
// adding a rule per port, which is how csf stores them anyway. // An addressed multi-port rule is native: an advanced line's port field
PortList: false, // is a comma list.
PortList: true,
PortPair: true,
ConnState: true, ConnState: true,
InterfaceMatch: true, InterfaceMatch: true,
Logging: true, Logging: true,
@ -113,6 +114,12 @@ func (f *CSF) Capabilities() Capabilities {
RuleCounters: false, RuleCounters: false,
AddressSets: true, AddressSets: true,
Comments: true, Comments: true,
Negation: true,
RejectAction: true,
FamilyWithoutAddress: true,
// A csf.deny entry stores no action; csf applies csf.conf's configured
// deny action, so removal matches an entry whatever action is named.
DenyActionFromConfig: true,
} }
} }
@ -288,6 +295,14 @@ func (f *CSF) ParseAdvRule(val string, action Action) (r *Rule) {
} }
} }
// csf.pl defaults a protocol-less advanced line to `-p tcp` (its $protocol
// starts as "-p tcp"), so mirror it: reading the line back as ProtocolAny
// would report an all-protocol rule csf does not enforce, and one whose
// removal the iptables validity check rejects.
if r.Proto == ProtocolAny {
r.Proto = TCP
}
return return
} }
@ -344,32 +359,16 @@ func (f *CSF) ParseIPList(filePath string, action Action) (rules []*Rule, err er
comment, hasPrefix := flushComment() comment, hasPrefix := flushComment()
if strings.Contains(trimmed, "|") { // parseListLine classifies an advanced line (pipe or colon delimited) or a
rule := f.ParseAdvRule(trimmed, action) // plain address line — one bidirectional DirAny rule authored in the
// inbound frame (Source=X) — and passes anything else through untouched.
rule := f.parseListLine(trimmed, action)
if rule == nil { if rule == nil {
continue continue
} }
rule.Comment = comment rule.Comment = comment
rule.HasPrefix = hasPrefix rule.HasPrefix = hasPrefix
rules = append(rules, rule) rules = append(rules, rule)
} else {
// Try to parse IP.
family, ok := parseAddrFamily(trimmed)
if !ok {
continue
}
// A plain IP line matches the host in both directions, so it is one
// bidirectional DirAny rule, authored in the inbound frame (Source=X).
rules = append(rules, &Rule{
Direction: DirAny,
Family: family,
Source: trimmed,
Action: action,
Comment: comment,
HasPrefix: hasPrefix,
})
}
} }
_ = fd.Close() _ = fd.Close()
@ -509,14 +508,19 @@ func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err
rules = append(rules, f.ParsePorts(val, IPv4, UDP, false)...) rules = append(rules, f.ParsePorts(val, IPv4, UDP, false)...)
case "UDP_OUT": case "UDP_OUT":
rules = append(rules, f.ParsePorts(val, IPv4, UDP, true)...) rules = append(rules, f.ParsePorts(val, IPv4, UDP, true)...)
case "TCP6_IN": case "TCP6_IN", "TCP6_OUT", "UDP6_IN", "UDP6_OUT":
rules = append(rules, f.ParsePorts(val, IPv6, TCP, false)...) // With IPV6 off csf never applies the *6_* lists, so their entries
case "TCP6_OUT": // are inert; reporting them would claim IPv6 coverage the firewall
rules = append(rules, f.ParsePorts(val, IPv6, TCP, true)...) // does not enforce.
case "UDP6_IN": if !f.ipv6Enabled {
rules = append(rules, f.ParsePorts(val, IPv6, UDP, false)...) continue
case "UDP6_OUT": }
rules = append(rules, f.ParsePorts(val, IPv6, UDP, true)...) proto := TCP
if strings.HasPrefix(key, "UDP") {
proto = UDP
}
out := strings.HasSuffix(key, "_OUT")
rules = append(rules, f.ParsePorts(val, IPv6, proto, out)...)
case "CONNLIMIT": case "CONNLIMIT":
rules = append(rules, f.ParseConnLimit(val)...) rules = append(rules, f.ParseConnLimit(val)...)
} }
@ -539,6 +543,8 @@ func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err
// with the action its direction actually gets rather than a fixed Reject — // with the action its direction actually gets rather than a fixed Reject —
// otherwise a Drop rule the caller manages reads back as Reject, never // otherwise a Drop rule the caller manages reads back as Reject, never
// compares equal to the desired rule, and churns on every Sync. // compares equal to the desired rule, and churns on every Sync.
// LF_BLOCKINONLY (off in stock csf.conf) is not modeled: with it set, csf
// skips a deny line's outbound rule while the line still reads back DirAny.
dropIn, dropOut := f.dropActions() dropIn, dropOut := f.dropActions()
ipRules, err = f.ParseIPList(CSFDeny, dropIn) ipRules, err = f.ParseIPList(CSFDeny, dropIn)
if err != nil { if err != nil {
@ -642,18 +648,46 @@ func (f *CSF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
if r.IsOutput() || r.Family == IPv4 || r.Proto == UDP { if r.IsOutput() || r.Family == IPv4 || r.Proto == UDP {
return orig return orig
} }
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
// With IPV6 off a family-agnostic add is written for IPv4 only (the
// v6 entry would sit inert and read back as unenforced coverage);
// removals still sweep, and a concrete-IPv6 row written by Restore
// keeps its family. The list-file analog is writeFamilyRows.
return orig
}
case "TCP6_OUT": case "TCP6_OUT":
if !r.IsOutput() || r.Family == IPv4 || r.Proto == UDP { if !r.IsOutput() || r.Family == IPv4 || r.Proto == UDP {
return orig return orig
} }
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
// With IPV6 off a family-agnostic add is written for IPv4 only (the
// v6 entry would sit inert and read back as unenforced coverage);
// removals still sweep, and a concrete-IPv6 row written by Restore
// keeps its family. The list-file analog is writeFamilyRows.
return orig
}
case "UDP6_IN": case "UDP6_IN":
if r.IsOutput() || r.Family == IPv4 || r.Proto == TCP { if r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
return orig return orig
} }
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
// With IPV6 off a family-agnostic add is written for IPv4 only (the
// v6 entry would sit inert and read back as unenforced coverage);
// removals still sweep, and a concrete-IPv6 row written by Restore
// keeps its family. The list-file analog is writeFamilyRows.
return orig
}
case "UDP6_OUT": case "UDP6_OUT":
if !r.IsOutput() || r.Family == IPv4 || r.Proto == TCP { if !r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
return orig return orig
} }
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
// With IPV6 off a family-agnostic add is written for IPv4 only (the
// v6 entry would sit inert and read back as unenforced coverage);
// removals still sweep, and a concrete-IPv6 row written by Restore
// keeps its family. The list-file analog is writeFamilyRows.
return orig
}
case "CONNLIMIT": case "CONNLIMIT":
// CONNLIMIT tokens are "port;limit", edited independently of the port // CONNLIMIT tokens are "port;limit", edited independently of the port
// lists above. // lists above.
@ -707,7 +741,8 @@ func (f *CSF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
// EditConf rewrites csf.conf to add or remove a port-list or CONNLIMIT rule. // EditConf rewrites csf.conf to add or remove a port-list or CONNLIMIT rule.
func (f *CSF) EditConf(ctx context.Context, r *Rule, remove bool) error { func (f *CSF) EditConf(ctx context.Context, r *Rule, remove bool) error {
// For port only rules, open the standard config file. // Open the standard config file; EditRulePort rewrites the port-list and
// CONNLIMIT lines the rule applies to.
fd, err := os.Open(CSFConf) fd, err := os.Open(CSFConf)
if err != nil { if err != nil {
return err return err
@ -726,7 +761,7 @@ func (f *CSF) EditConf(ctx context.Context, r *Rule, remove bool) error {
for scanner.Scan() { for scanner.Scan() {
// Get the line. // Get the line.
orig := scanner.Text() orig := scanner.Text()
line := orig[:] line := orig
// Remove comments. // Remove comments.
ci := strings.IndexByte(line, '#') ci := strings.IndexByte(line, '#')
@ -834,12 +869,18 @@ func (f *CSF) parseListLine(line string, action Action) *Rule {
if strings.Contains(line, "|") { if strings.Contains(line, "|") {
return f.ParseAdvRule(line, action) return f.ParseAdvRule(line, action)
} }
family, ok := parseAddrFamily(line) if family, ok := parseAddrFamily(line); ok {
if !ok {
return nil
}
return &Rule{Direction: DirAny, Family: family, Source: line, Action: action} return &Rule{Direction: DirAny, Family: family, Source: line, Action: action}
} }
// csf.pl accepts a colon-delimited advanced line, converting `:` to `|` when
// the line carries no pipe; an address (IPv6 included) was classified above,
// so the conversion never touches one. An advanced line always carries an s=
// or d= field, which keeps other unparseable colon text opaque.
if strings.Contains(line, ":") && strings.Contains(line, "=") {
return f.ParseAdvRule(strings.ReplaceAll(line, ":", "|"), action)
}
return nil
}
// listRows returns the csf.allow/csf.deny rows a rule materializes into, in write // listRows returns the csf.allow/csf.deny rows a rule materializes into, in write
// order, or none for a shape the lists cannot hold. The rule must already carry the // order, or none for a shape the lists cannot hold. The rule must already carry the
@ -869,7 +910,7 @@ func (f *CSF) listRows(action Action, match *Rule) []listRow {
// A bare all-protocol host allow/deny: a single address matching every // A bare all-protocol host allow/deny: a single address matching every
// protocol. csf.allow/csf.deny hold no other portless address shape — a // protocol. csf.allow/csf.deny hold no other portless address shape — a
// concrete-protocol host or a source+destination pair — so AddRule diverts // concrete-protocol host or a source+destination pair — so AddRule diverts
// those to the raw-iptables hook (hostNeedsHook) and never reaches here with // those to the raw-iptables hook (shapeNeedsHook) and never reaches here with
// one. A direct caller of this exported writer that supplies such a shape gets // one. A direct caller of this exported writer that supplies such a shape gets
// a best-effort single-address write, not a guard. // a best-effort single-address write, not a guard.
addr := match.Source addr := match.Source
@ -890,7 +931,7 @@ func (f *CSF) listRows(action Action, match *Rule) []listRow {
} }
for _, sub := range expandProtocols(fam) { for _, sub := range expandProtocols(fam) {
// The row reads back address-less; only the line carries the placeholder. // The row reads back address-less; only the line carries the placeholder.
// advRuleNeedsHook has already routed an address-less source-port match to // shapeNeedsHook has already routed an address-less source-port match to
// the hook, so the address field being filled in here is free. // the hook, so the address field being filled in here is free.
row := *sub row := *sub
if row.IsOutput() { if row.IsOutput() {
@ -1051,26 +1092,13 @@ func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r
return af.Commit() return af.Commit()
} }
// nativeICMP reports whether an ICMPv4 rule maps onto a csf advanced rule and so
// belongs in csf.allow/csf.deny rather than the raw-iptables hook. An advanced line
// requires an address, and its single port-flow field carries the icmp type, so only
// a rule with exactly one address and a concrete type has a faithful form: csf.pl's
// linefilter reads that field by position, and an address with no type would land the
// address there (`--icmp-type <ip>`), which csf fails to parse and drops silently. A
// source+destination pair overflows the single address field and is routed by
// advRuleNeedsHook. ICMPv6 never reaches this test — ruleNeedsHook sends it to the
// hook, or the IPv6 gate rejects it.
func (f *CSF) nativeICMP(r *Rule) bool {
return r.Proto == ICMP && r.ICMPType != nil && (r.Source != "") != (r.Destination != "")
}
// needsHook reports whether a rule must be injected through the csf pre-hook as a // needsHook reports whether a rule must be injected through the csf pre-hook as a
// raw iptables rule because csf's native config cannot express it. It is the single // raw iptables rule because csf's native config cannot express it. It is the single
// gate between the hook path and csf's config files: everything it rejects (returns // gate between the hook path and csf's config files: everything it rejects (returns
// true) is written to the hook, everything it accepts (returns false) maps onto // true) is written to the hook, everything it accepts (returns false) maps onto
// csf.conf or the csf.allow/csf.deny lists. The shared shapes (ruleNeedsHook, // csf.conf or the csf.allow/csf.deny lists. The shared predicates (ruleNeedsHook,
// bareHostOneWay, hostNeedsHook, advRuleNeedsHook, bareProtoNeedsHook) keep their own // shapeNeedsHook, bareHostOneWay) stay standalone, since APF shares them and
// predicates, since RemoveRule routes on ruleNeedsHook and bareHostOneWay directly. // RemoveRule routes on ruleNeedsHook and bareHostOneWay directly.
func (f *CSF) needsHook(r *Rule) bool { func (f *CSF) needsHook(r *Rule) bool {
// 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, forward-chain routing, icmpv6, a transport // interface, logging, rate limiting, forward-chain routing, icmpv6, a transport
@ -1078,17 +1106,18 @@ func (f *CSF) needsHook(r *Rule) bool {
if ruleNeedsHook(r) { if ruleNeedsHook(r) {
return true return true
} }
// A one-way bare host has no csf.allow/csf.deny form (a plain line is // A shape no native csf form holds — a one-way bare host, a source+destination
// bidirectional, an advanced rule needs a port), and a concrete-protocol host or a // pair, a concrete-protocol portless host, an advanced-line address/port-flow
// source+destination pair likewise has none (see hostNeedsHook); all go to the hook. // overflow, or a bare protocol match (see shapeNeedsHook) — goes to the hook.
if bareHostOneWay(r) || hostNeedsHook(r) { if shapeNeedsHook(r) {
return true return true
} }
// A ported source+destination pair, a source port matched with a destination port, // An address-less multi-port accept goes to the hook's `-m multiport` match:
// and an address-less source-port match all overflow the advanced line's single // a csf.conf port list stores each port as an independent token that reads
// address and single port-flow field (see advRuleNeedsHook); iptables matches each // back as its own rule, so the list shape has no single native form (see
// directly, so they go to the hook. // multiPortConfAccept). An addressed multi-port rule stays native — an
if advRuleNeedsHook(r) { // advanced line's port field is a comma list.
if f.multiPortConfAccept(r) {
return true return true
} }
// A connection limit csf.conf's CONNLIMIT cannot express — anything but a // A connection limit csf.conf's CONNLIMIT cannot express — anything but a
@ -1097,16 +1126,32 @@ func (f *CSF) needsHook(r *Rule) bool {
if r.ConnLimit != nil && !f.isConnLimitRule(r) { if r.ConnLimit != nil && !f.isConnLimitRule(r) {
return true return true
} }
// An ICMPv4 rule csf's advanced-rule format cannot carry (see nativeICMP) goes to // An ICMPv4 rule csf's advanced-rule format cannot carry goes to the hook's
// the hook's `iptables -p icmp` match, which needs neither an address nor a type. // `iptables -p icmp` match, which needs neither an address nor a type. The only
if r.Proto == ICMP && !f.nativeICMP(r) { // native form is exactly one address with a concrete type: an advanced line
return true // requires an address, its single port-flow field carries the icmp type, and
// csf.pl's linefilter reads that field by position — an address with no type
// would land the address there (`--icmp-type <ip>`), which csf fails to parse
// and drops silently. A source+destination pair overflows the single address
// field and is routed by shapeNeedsHook above; ICMPv6 never reaches this test —
// ruleNeedsHook sends it to the hook, or the IPv6 gate rejects it.
return r.Proto == ICMP && !(r.ICMPType != nil && (r.Source != "") != (r.Destination != ""))
} }
// 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 // multiPortConfAccept reports whether a rule is an address-less tcp/udp accept
// on an address — but iptables expresses it directly, so it goes to the hook (see // carrying a discrete multi-port list — the one port shape a csf.conf port list
// bareProtoNeedsHook). ICMP is excluded there and routed above. // cannot hold as a single rule: TCP_IN="80,443" stores independent tokens that
return bareProtoNeedsHook(r) // read back one rule per port, so the rule would never round-trip whole. AddRule
// injects it through the hook's `-m multiport` match, which keeps the list on
// one line; a single port or one range stays a native token. A connection-limited
// rule is excluded (CONNLIMIT routing owns it), as is a source-port match, which
// is not the port-list shape and is routed by shapeNeedsHook — the exclusions let
// RemoveRule sweep the csf.conf port lists for this shape without touching tokens
// other rules own.
func (f *CSF) multiPortConfAccept(r *Rule) bool {
return onProtocolAxis(r.Proto) && r.Action == Accept && r.ConnLimit == nil &&
r.Source == "" && r.Destination == "" && !r.HasSourcePorts() &&
len(r.PortSpecs()) > 1
} }
// denyAction returns the action a csf.deny entry takes in the given direction, // denyAction returns the action a csf.deny entry takes in the given direction,
@ -1168,10 +1213,11 @@ func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6
// Any shape csf's native config cannot express (a stateful/interface/logged/ // Any shape csf's native config cannot express (a stateful/interface/logged/
// rate-limited rule, a one-way or concrete-protocol host, a source+destination // rate-limited rule, a one-way or concrete-protocol host, a source+destination
// pair, a source-and-destination port match, an address-less source-port match, a // pair, a source-and-destination port match, an address-less source-port match,
// non-native connection limit, a non-native ICMPv4 rule, or a bare protocol match) // an address-less multi-port accept, a non-native connection limit, a non-native
// is injected as a raw iptables rule through the csf pre-hook. See needsHook for // ICMPv4 rule, or a bare protocol match) is injected as a raw iptables rule
// each clause; everything past this gate maps onto csf's own config files. // through the csf pre-hook. See needsHook for each clause; everything past this
// gate maps onto csf's own config files.
if f.needsHook(r) { if f.needsHook(r) {
_, err := f.hook().edit(r, false) _, err := f.hook().edit(r, false)
return err return err
@ -1182,12 +1228,12 @@ func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6
return f.EditConf(ctx, r, false) return f.EditConf(ctx, r, false)
} }
// A port-only accept maps to a csf.conf port list rather than csf.allow. // A port-only accept maps to a csf.conf port list rather than csf.allow;
// listRows has no row for it, so falling through to the csf.allow edit would
// only rewrite that file byte-identically. Only a single port or one range
// reaches here — needsHook diverted a multi-port list to the hook above.
if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept { if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept {
err := f.EditConf(ctx, r, false) return f.EditConf(ctx, r, false)
if err != nil {
return err
}
} }
// Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny // Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny
@ -1265,6 +1311,13 @@ func (f *CSF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule
return err return err
} }
if s := splitDualRowDirection(e, r); s != nil { if s := splitDualRowDirection(e, r); s != nil {
// A deny plain line's outbound half was enforced with csf.conf's
// DROP_OUT action, while the DirAny row reads back with the inbound
// action; re-express the survivor with the action csf actually applied
// so the split does not silently change wire behavior.
if s.Action != Accept && s.IsOutput() {
_, s.Action = f.dropActions()
}
_, err := f.hook().edit(s, false) _, err := f.hook().edit(s, false)
return err return err
} }
@ -1301,6 +1354,14 @@ func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return nil return nil
} }
// Validate the shape before the hook sweep below: an iptables-inexpressible
// rule (a port on ProtocolAny) exists nowhere csf can hold it, and letting it
// fail inside the hook marshal would return the bare error without the
// sentinel AddRule attaches to the same shape.
if err := iptablesRuleValid(r); err != nil {
return fmt.Errorf("%v: %w", err, ErrUnsupported)
}
// Clear any hook copy of the rule first, no matter how csf stores it. A rule csf // Clear any hook copy of the rule first, no matter how csf stores it. A rule csf
// carries only in the hook (see needsHook) lives nowhere else, so this is its // carries only in the hook (see needsHook) lives nowhere else, so this is its
// entire removal; a natively-expressible rule may still have a stray hook copy — // entire removal; a natively-expressible rule may still have a stray hook copy —
@ -1331,12 +1392,12 @@ func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
} }
// Every other shape csf's native config cannot express (see needsHook) has already // Every other shape csf's native config cannot express (see needsHook) has already
// had its hook copy cleared above and has no native entry to split, so it is done. // had its hook copy cleared above and has no native entry to split, so it is done.
if f.needsHook(r) { // An address-less multi-port accept is the exception: it lives in the hook now,
// but an earlier per-port add (or a manual edit) may also hold its ports as
// csf.conf tokens, so it falls through to the port-list sweep below.
if f.needsHook(r) && !f.multiPortConfAccept(r) {
return nil return nil
} }
if err := iptablesRuleValid(r); err != nil {
return fmt.Errorf("%v: %w", err, ErrUnsupported)
}
// A native connection-limit rule maps onto the csf.conf CONNLIMIT list. // A native connection-limit rule maps onto the csf.conf CONNLIMIT list.
if r.ConnLimit != nil { if r.ConnLimit != nil {
@ -1433,19 +1494,51 @@ func (f *CSF) UnmarshalNATRule(line string) *NATRule {
return r return r
} }
// GetNATRules reads the NAT rules from csf.redirect. // natNeedsHook reports whether a NAT rule has no csf.redirect form and must be
func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) { // injected as a raw nat-table command through the pre-hook instead. csf.redirect
fd, err := os.Open(CSFRedirect) // encodes destination NAT only, in exactly two DNAT shapes plus the local
if err != nil { // redirect, always tcp/udp on a single concrete port, with no source or
// csf.redirect is optional; a missing file simply has no rules. // interface match (see MarshalNATRule). iptables expresses all of the excess —
if os.IsNotExist(err) { // source NAT, an interface binding, a port range — directly, so those shapes are
return nil, nil // hooked rather than rejected.
func (f *CSF) natNeedsHook(r *NATRule) bool {
if r.Kind.isSource() {
return true
} }
return nil, err if r.Proto != TCP && r.Proto != UDP {
return true
}
if r.HasPortSet() {
return true
}
if r.Source != "" || r.Interface != "" {
return true
}
// A csf.redirect line carries family only through its addresses, so a family
// pinned by the Family field alone (an address-less redirect) has no native
// form: it would read back family-agnostic and never reconcile.
if r.Family != FamilyAny && familyOfAddr(r.Destination) == FamilyAny && familyOfAddr(r.ToAddress) == FamilyAny {
return true
}
switch r.Kind {
case Redirect:
return r.Port == 0
case DNAT:
// csf.pl accepts a DNAT only as a full-IP forward (both ports "*") or a
// port forward (both ports concrete); any other pairing aborts the load.
return r.Destination == "" || (r.Port == 0) != (r.ToPort == 0)
}
return true
} }
defer func() { _ = fd.Close() }()
// GetNATRules reads the NAT rules from csf.redirect and the raw nat-table
// commands the pre-hook carries.
func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
var rules []*NATRule var rules []*NATRule
fd, err := os.Open(CSFRedirect)
switch {
case err == nil:
defer func() { _ = fd.Close() }()
scanner := bufio.NewScanner(fd) scanner := bufio.NewScanner(fd)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
@ -1463,10 +1556,20 @@ func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, err
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return nil, err return nil, err
} }
case os.IsNotExist(err):
// csf.redirect is optional; a missing file simply has no native rules.
default:
return nil, 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). Hook NAT lines are raw iptables commands, so a rule this
return rules, nil // library added there carries the prefix in its -m comment tag instead.
hookNAT, err := f.hook().getNATRules()
if err != nil {
return nil, err
}
return append(rules, hookNAT...), 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
@ -1491,7 +1594,8 @@ func (f *CSF) redirectPort(p uint16) string {
// ("IPx|portA|IPy|portB|proto"). csf.redirect expresses only destination NAT: a // ("IPx|portA|IPy|portB|proto"). csf.redirect expresses only destination NAT: a
// Redirect to a local port (IPy = "*") or a DNAT forward to another host // Redirect to a local port (IPy = "*") or a DNAT forward to another host
// (IPy = ToAddress). Source NAT, port ranges/lists, and non-tcp/udp protocols // (IPy = ToAddress). Source NAT, port ranges/lists, and non-tcp/udp protocols
// are not representable. // have no line here — AddNATRule routes those shapes through the pre-hook
// (natNeedsHook), so the rejections below guard only direct marshal callers.
func (f *CSF) MarshalNATRule(r *NATRule) (string, error) { func (f *CSF) MarshalNATRule(r *NATRule) (string, error) {
if err := r.validate(); err != nil { if err := r.validate(); err != nil {
return "", err return "", err
@ -1603,8 +1707,24 @@ func (f *CSF) editRedirect(r *NATRule, remove bool) error {
return writeConfigFile(CSFRedirect, []byte(content), 0600) return writeConfigFile(CSFRedirect, []byte(content), 0600)
} }
// AddNATRule adds a NAT rule to csf.redirect. // AddNATRule adds a NAT rule to csf.redirect, or — for the shapes csf.redirect
// cannot hold (natNeedsHook) — as a raw nat-table command in the pre-hook.
func (f *CSF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { func (f *CSF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
if err := r.validate(); err != nil {
return err
}
// A concrete-IPv6 translation cannot be kept in sync with IPV6 off: csf
// sources the pre-hook on every (re)load but only flushes the v6 nat table
// when IPV6 is on, so an injected ip6tables line would re-append each reload
// and outlive its own removal, and a csf.redirect v6 entry is never applied
// at all — the NAT analog of ipv6Unavailable.
if !f.ipv6Enabled && r.impliedFamily() == IPv6 {
return fmt.Errorf("csf cannot manage an IPv6 nat rule with IPV6 disabled: %w", ErrUnsupportedNAT)
}
if f.natNeedsHook(r) {
_, err := f.hook().editNAT(r, false)
return err
}
return f.editRedirect(r, false) return f.editRedirect(r, false)
} }
@ -1614,8 +1734,20 @@ func (f *CSF) InsertNATRule(ctx context.Context, zoneName string, position int,
return unsupportedOrdering(f.Type()) return unsupportedOrdering(f.Type())
} }
// RemoveNATRule removes a NAT rule from csf.redirect. // RemoveNATRule removes a NAT rule from csf.redirect and from the pre-hook.
// The hook is swept first whatever the rule's shape: a hook-only shape lives
// nowhere else, and a natively-expressible rule may still have a stray hook
// copy a customer added by hand, mirroring the filter-side RemoveRule.
func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
if err := r.validate(); err != nil {
return err
}
if _, err := f.hook().editNAT(r, true); err != nil {
return err
}
if f.natNeedsHook(r) {
return nil
}
return f.editRedirect(r, true) return f.editRedirect(r, true)
} }
@ -1659,6 +1791,7 @@ func (f *CSF) AddAddressSet(ctx context.Context, set *AddressSet) error {
} }
// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a // RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a
// hook rule still references the set; removing an absent set is a no-op.
func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error { func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error {
_, err := f.hook().editAddressSet(&AddressSet{Name: name}, true) _, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
return err return err

View file

@ -171,16 +171,16 @@ func TestCSFAdvRuleTCPUDPWithAddressFansOut(t *testing.T) {
// unenforceable line. // unenforceable line.
func TestCSFAdvRuleAddressWithoutPortRouted(t *testing.T) { func TestCSFAdvRuleAddressWithoutPortRouted(t *testing.T) {
fw := new(CSF) fw := new(CSF)
require.True(t, hostNeedsHook(&Rule{Family: IPv4, Proto: TCP, Source: "192.0.2.10", Action: Drop}), require.True(t, shapeNeedsHook(&Rule{Family: IPv4, Proto: TCP, Source: "192.0.2.10", Action: Drop}),
"a tcp host with no port must go to the hook, not an advanced line") "a tcp host with no port must go to the hook, not an advanced line")
require.True(t, hostNeedsHook(&Rule{Family: IPv4, Proto: UDP, Destination: "192.0.2.10", Action: Accept}), require.True(t, shapeNeedsHook(&Rule{Family: IPv4, Proto: UDP, Destination: "192.0.2.10", Action: Accept}),
"a udp host with no port must go to the hook, not an advanced line") "a udp host with no port must go to the hook, not an advanced line")
// An ICMP rule with an address and no type takes its own route: csf's linefilter // An ICMP rule with an address and no type takes its own route: csf's linefilter
// would consume the address as the icmp-type field, so it goes to the hook, whose // would consume the address as the icmp-type field, so it goes to the hook, whose
// iptables rule needs no type. // iptables rule needs no type.
icmp := &Rule{Family: IPv4, Proto: ICMP, Source: "192.0.2.10", Action: Accept} icmp := &Rule{Family: IPv4, Proto: ICMP, Source: "192.0.2.10", Action: Accept}
require.False(t, hostNeedsHook(icmp), "icmp keeps its own handling") require.False(t, shapeNeedsHook(icmp), "icmp keeps its own handling")
require.True(t, fw.needsHook(icmp), "a typeless icmp host must be routed to the hook") require.True(t, fw.needsHook(icmp), "a typeless icmp host must be routed to the hook")
} }
@ -306,12 +306,54 @@ func TestCSFSourcePorts(t *testing.T) {
// a destination port has no advanced-line form; neither does an address-less // a destination port has no advanced-line form; neither does an address-less
// source port, since an advanced line requires an address. AddRule routes both to // source port, since an advanced line requires an address. AddRule routes both to
// the raw-iptables hook rather than marshalling them. // the raw-iptables hook rather than marshalling them.
require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}), require.True(t, shapeNeedsHook(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}),
"a dual-port rule must be routed to the hook") "a dual-port rule must be routed to the hook")
require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}), require.True(t, shapeNeedsHook(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}),
"an address-less source-port rule must be routed to the hook") "an address-less source-port rule must be routed to the hook")
} }
// TestCSFMultiPortRouting pins where a discrete multi-port list goes. An
// address-less multi-port accept has no single native form — a csf.conf port
// list stores each port as an independent token that reads back as its own rule
// — so it is injected through the hook's multiport match and round-trips whole;
// single-token shapes, addressed lists (an advanced line's port field is a
// comma list), and the port-only deny fan-out stay native.
func TestCSFMultiPortRouting(t *testing.T) {
fw := new(CSF)
list := []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}
hooked := &Rule{Proto: TCP, Ports: list, Action: Accept}
require.True(t, fw.multiPortConfAccept(hooked), "an address-less multi-port accept is the conf-list overflow shape")
require.True(t, fw.needsHook(hooked), "an address-less multi-port accept must route to the hook")
lines, err := (&hookScript{}).rulesToLines(hooked)
require.NoError(t, err, "the hook must be able to render the multi-port rule")
require.Len(t, lines, 1)
require.Contains(t, lines[0], "-m multiport --dports 80,443")
native := []*Rule{
{Proto: TCP, Port: 80, Action: Accept}, // single port token
{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, // one range token
{Proto: TCP, Ports: list, Source: "1.2.3.4", Action: Accept}, // advanced line comma list
{Proto: TCP, Ports: list, Action: Drop}, // port-only deny placeholder rows
}
for _, r := range native {
require.False(t, fw.needsHook(r), "expected native routing for %+v", *r)
}
}
// TestCSFMultiPortRemovalSweepsConfTokens covers removal of a multi-port accept
// against a csf.conf port list: the rule lives in the hook now, but an earlier
// per-port add (or a manual edit) may hold the same ports as list tokens, so
// RemoveRule falls through to the port-list sweep, which strips exactly the
// target's tokens and no others.
func TestCSFMultiPortRemovalSweepsConfTokens(t *testing.T) {
fw := new(CSF)
target := &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
require.Equal(t, `TCP_IN = "22"`,
fw.EditRulePort(`TCP_IN = "22,80,443"`, "TCP_IN", "22,80,443", target, true),
"a multi-port removal must strip each of its own port tokens and keep the rest")
}
// Only an ICMP rule with exactly one address and a concrete type is a csf advanced // Only an ICMP rule with exactly one address and a concrete type is a csf advanced
// rule; every other ICMP shape is routed to the raw-iptables hook, which needs // rule; every other ICMP shape is routed to the raw-iptables hook, which needs
// neither. csf's linefilter would otherwise consume the address as the icmp-type // neither. csf's linefilter would otherwise consume the address as the icmp-type
@ -321,7 +363,6 @@ func TestCSFICMPRouting(t *testing.T) {
// With one address and a concrete type it is a valid advanced rule and round-trips. // With one address and a concrete type it is a valid advanced rule and round-trips.
typed := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept} typed := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept}
require.True(t, fw.nativeICMP(typed))
require.False(t, fw.needsHook(typed), "a typed icmp host is a native advanced rule") require.False(t, fw.needsHook(typed), "a typed icmp host is a native advanced rule")
line := fw.MarshalAdvRule(typed) line := fw.MarshalAdvRule(typed)
require.Equal(t, "icmp|in|d=8|s=1.2.3.4", line) require.Equal(t, "icmp|in|d=8|s=1.2.3.4", line)
@ -339,7 +380,6 @@ func TestCSFICMPRouting(t *testing.T) {
} }
h := &hookScript{} h := &hookScript{}
for _, r := range hooked { for _, r := range hooked {
require.False(t, fw.nativeICMP(r), "expected non-native icmp for %+v", *r)
require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r) require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r)
lines, err := h.rulesToLines(r) lines, err := h.rulesToLines(r)
require.NoError(t, err, "the hook must be able to render %+v", *r) require.NoError(t, err, "the hook must be able to render %+v", *r)
@ -578,9 +618,9 @@ func TestCSFDropRuleRemovable(t *testing.T) {
// A ported csf advanced rule that matches both a source and a destination address // A ported csf advanced rule that matches both a source and a destination address
// cannot be expressed (a csf advanced rule holds a single address field), so AddRule // cannot be expressed (a csf advanced rule holds a single address field), so AddRule
// routes it to the raw-iptables hook rather than silently dropping the destination. // routes it to the raw-iptables hook rather than silently dropping the destination.
// The portless bare form takes the same path one predicate earlier (hostNeedsHook). // The portless bare form takes the same path (shapeNeedsHook routes both).
func TestCSFDualAddressRouted(t *testing.T) { func TestCSFDualAddressRouted(t *testing.T) {
require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, Port: 80, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}), require.True(t, shapeNeedsHook(&Rule{Proto: TCP, Port: 80, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}),
"a ported dual-address rule must be routed to the hook, never marshalled as an advanced line") "a ported dual-address rule must be routed to the hook, never marshalled as an advanced line")
} }
@ -672,17 +712,17 @@ func TestCSFDNATMarshalRejectsUnexpressible(t *testing.T) {
// (a bare protocol match) is injected through the pre-hook rather than rejected or // (a bare protocol match) is injected through the pre-hook rather than rejected or
// silently written nowhere. csf keys every native rule on a port or an address, so // silently written nowhere. csf keys every native rule on a port or an address, so
// such a rule maps to no csf construct; iptables expresses it directly, so addRule // such a rule maps to no csf construct; iptables expresses it directly, so addRule
// diverts it to the hook (bareProtoNeedsHook). ICMP keeps its own handling. // diverts it to the hook (shapeNeedsHook). ICMP keeps its own handling.
func TestCSFBareProtocolRoutesToHook(t *testing.T) { func TestCSFBareProtocolRoutesToHook(t *testing.T) {
for _, r := range []*Rule{ for _, r := range []*Rule{
{Proto: TCP, Action: Accept}, {Proto: TCP, Action: Accept},
{Proto: ProtocolAny, Action: Accept}, {Proto: ProtocolAny, Action: Accept},
{Proto: UDP, Action: Drop}, {Proto: UDP, Action: Drop},
} { } {
require.True(t, bareProtoNeedsHook(r), require.True(t, shapeNeedsHook(r),
"a portless, addressless rule must route to the hook, not be rejected: %+v", r) "a portless, addressless rule must route to the hook, not be rejected: %+v", r)
} }
require.False(t, bareProtoNeedsHook(&Rule{Proto: ICMP, Action: Accept}), require.False(t, shapeNeedsHook(&Rule{Proto: ICMP, Action: Accept}),
"an ICMP rule keeps its own handling and is excluded from the bare-protocol hook route") "an ICMP rule keeps its own handling and is excluded from the bare-protocol hook route")
} }
@ -739,7 +779,7 @@ func TestCSFPortOnlyRejectRoundTrip(t *testing.T) {
// A bare all-protocol host rule (address, no port) is the one portless address // A bare all-protocol host rule (address, no port) is the one portless address
// shape csf.allow/csf.deny express, written as the plain address line. The // shape csf.allow/csf.deny express, written as the plain address line. The
// inexpressible shapes — a concrete-protocol host or a source+destination pair — // inexpressible shapes — a concrete-protocol host or a source+destination pair —
// are diverted to the hook by AddRule (hostNeedsHook) and never reach this // are diverted to the hook by AddRule (shapeNeedsHook) and never reach this
// writer, so only the legitimate write is exercised here. // writer, so only the legitimate write is exercised here.
func TestCSFBareHostWritten(t *testing.T) { func TestCSFBareHostWritten(t *testing.T) {
fw := new(CSF) fw := new(CSF)
@ -925,3 +965,110 @@ func TestCSFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
require.NotContains(t, string(data), "d=80", require.NotContains(t, string(data), "d=80",
"removal must sweep the stale IPv6 line even with IPv6 off") "removal must sweep the stale IPv6 line even with IPv6 off")
} }
// A protocol-less advanced line is enforced by csf.pl as `-p tcp` (its protocol
// default), so it must read back as TCP: a ProtocolAny read-back would report an
// all-protocol rule csf does not enforce, and one whose removal the iptables
// validity check rejects.
func TestCSFAdvRuleProtocolDefaultsTCP(t *testing.T) {
fw := new(CSF)
r := fw.ParseAdvRule("in|d=80|s=192.0.2.1", Drop)
require.NotNil(t, r)
require.Equal(t, TCP, r.Proto, "csf enforces a protocol-less advanced line as tcp")
require.EqualValues(t, 80, r.Port)
require.Equal(t, "192.0.2.1", r.Source)
}
// csf.pl accepts a colon-delimited advanced line (converting `:` to `|` when the
// line has no pipe), so the parser must read it; an IPv6 literal keeps parsing as
// a plain address, never as a colon-delimited rule.
func TestCSFColonDelimitedAdvRule(t *testing.T) {
fw := new(CSF)
r := fw.parseListLine("tcp:in:d=22:s=192.0.2.1", Drop)
require.NotNil(t, r, "a colon-delimited advanced line must parse")
require.Equal(t, TCP, r.Proto)
require.EqualValues(t, 22, r.Port)
require.Equal(t, "192.0.2.1", r.Source)
v6 := fw.parseListLine("2001:db8::7", Drop)
require.NotNil(t, v6)
require.Equal(t, "2001:db8::7", v6.Source, "an IPv6 literal is a plain address line")
require.Equal(t, DirAny, v6.Direction)
}
// With csf.conf's IPV6 off, a family-agnostic port add must not touch the *6_*
// lists (their entries would be inert), a removal must still sweep them, and a
// concrete-IPv6 row written by Restore keeps its family.
func TestCSFEditRulePortIPv6Disabled(t *testing.T) {
off := &CSF{}
anyFam := &Rule{Proto: TCP, Port: 8080, Action: Accept}
got := off.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", anyFam, false)
require.Equal(t, `TCP6_IN = "22"`, got, "a family-agnostic add must not touch TCP6_IN while IPv6 is off")
got = off.EditRulePort(`TCP_IN = "22"`, "TCP_IN", "22", anyFam, false)
require.Contains(t, got, "8080", "the IPv4 list still takes the add")
// Removal sweeps the inert v6 entry so it does not outlive the rule.
got = off.EditRulePort(`TCP6_IN = "22,8080"`, "TCP6_IN", "22,8080", anyFam, true)
require.NotContains(t, got, "8080", "a removal must sweep the v6 list even with IPv6 off")
// A concrete-IPv6 rule (Restore reproducing a snapshot) keeps its family.
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Accept}
got = off.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", v6, false)
require.Contains(t, got, "8080", "a concrete-IPv6 write keeps its family")
on := &CSF{ipv6Enabled: true}
got = on.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", anyFam, false)
require.Contains(t, got, "8080", "with IPv6 on the v6 list takes the add")
}
// RemoveRule of an iptables-inexpressible shape must reject with the sentinel
// before sweeping the hook, mirroring AddRule, so callers and the integration
// harness can tell "unsupported" from a real failure.
func TestCSFRemoveRuleUnsupportedShapeSentinel(t *testing.T) {
fw := new(CSF)
err := fw.RemoveRule(context.Background(), "", &Rule{Proto: ProtocolAny, Port: 80, Action: Accept})
require.ErrorIs(t, err, ErrUnsupported, "an inexpressible removal must carry the sentinel")
}
// natNeedsHook routes every shape csf.redirect cannot hold to the pre-hook:
// source NAT, non-tcp/udp, port sets, source/interface matches, an address-less
// family pin, and the DNAT pairings csf.pl aborts on. The two native shapes stay
// on csf.redirect.
func TestCSFNATNeedsHook(t *testing.T) {
fw := new(CSF)
native := []*NATRule{
{Kind: Redirect, Proto: TCP, Port: 666, ToPort: 25},
{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1", ToPort: 25},
{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", ToAddress: "10.0.0.1"},
}
for _, r := range native {
require.False(t, fw.natNeedsHook(r), "csf.redirect holds %+v natively", *r)
}
hooked := []*NATRule{
{Kind: SNAT, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"},
{Kind: Masquerade, Interface: "eth1"},
{Kind: DNAT, Proto: ICMP, ToAddress: "10.0.0.1"},
{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "10.0.0.1"},
{Kind: Redirect, Proto: TCP, Port: 8080, Source: "192.0.2.0/24", ToPort: 80},
{Kind: DNAT, Proto: TCP, Interface: "eth0", Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1", ToPort: 25},
// A family pinned by nothing an address carries reads back agnostic from a
// csf.redirect line, so it must live in the hook to reconcile.
{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 8080, ToPort: 80},
// csf.pl aborts the redirect load on a half-set port pairing.
{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1"},
{Kind: DNAT, Proto: TCP, ToAddress: "10.0.0.1"},
}
for _, r := range hooked {
require.True(t, fw.natNeedsHook(r), "%+v has no csf.redirect form and must hook", *r)
}
}
// With IPV6 off a concrete-IPv6 NAT rule is rejected outright: csf neither
// applies a v6 redirect nor flushes the v6 nat table, so neither store could
// keep the rule in sync (the NAT analog of ipv6Unavailable, mirroring apf).
func TestCSFNATIPv6Gating(t *testing.T) {
off := new(CSF)
err := off.AddNATRule(context.Background(), "", &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 8080, ToAddress: "2001:db8::5"})
require.ErrorIs(t, err, ErrUnsupportedNAT, "a concrete-IPv6 nat add must be rejected while IPV6 is off")
}

View file

@ -12,7 +12,7 @@ import (
// Sentinel errors a caller can match with errors.Is to tell apart a genuine // Sentinel errors a caller can match with errors.Is to tell apart a genuine
// failure from a feature the active backend cannot express. The helpers below // failure from a feature the active backend cannot express. The helpers below
// (rejectLogAndLimit, unsupportedNAT, ...) wrap these, and per-backend marshal // (unsupportedNAT, unsupportedOrdering, ...) wrap these, and per-backend marshal
// paths use fmt.Errorf("...: %w", err) so the message stays readable while the // paths use fmt.Errorf("...: %w", err) so the message stays readable while the
// sentinel is preserved for programmatic handling. // sentinel is preserved for programmatic handling.
var ( var (
@ -112,7 +112,8 @@ func (t Family) String() string {
} }
// ParseFamily parses a family token (case-insensitive), accepting the canonical // ParseFamily parses a family token (case-insensitive), accepting the canonical
// name emitted by Family.String. An unknown value is an error. // name emitted by Family.String plus the common aliases (v4/inet4, v6/inet6).
// An unknown value is an error.
func ParseFamily(s string) (Family, error) { func ParseFamily(s string) (Family, error) {
switch strings.ToLower(strings.TrimSpace(s)) { switch strings.ToLower(strings.TrimSpace(s)) {
case "any": case "any":
@ -238,10 +239,12 @@ var icmpNameToNum = map[string]uint8{
"traceroute": 30, "traceroute": 30,
} }
// icmpv6NameToNum maps the ICMPv6 type names nftables and other tools print to // icmpv6NameToNum maps the ICMPv6 type names nftables, ip6tables and ufw print
// their numeric type. ICMPv6 reuses several names from ICMPv4 (echo-request, // to their numeric type (both the nftables nd-* spellings and the ip6tables
// destination-unreachable, ...) for *different* numbers, so a name read from an // long forms such as router-solicitation). ICMPv6 reuses several names from
// ICMPv6 rule must be resolved through this table rather than icmpNameToNum. // ICMPv4 (echo-request, destination-unreachable, ...) for *different* numbers,
// so a name read from an ICMPv6 rule must be resolved through this table rather
// than icmpNameToNum.
var icmpv6NameToNum = map[string]uint8{ var icmpv6NameToNum = map[string]uint8{
"destination-unreachable": 1, "destination-unreachable": 1,
"packet-too-big": 2, "packet-too-big": 2,
@ -257,10 +260,17 @@ var icmpv6NameToNum = map[string]uint8{
"mld-listener-done": 132, "mld-listener-done": 132,
"mld-listener-reduction": 132, "mld-listener-reduction": 132,
"nd-router-solicit": 133, "nd-router-solicit": 133,
"router-solicitation": 133,
"nd-router-advert": 134, "nd-router-advert": 134,
"router-advertisement": 134,
"nd-neighbor-solicit": 135, "nd-neighbor-solicit": 135,
"neighbor-solicitation": 135,
"neighbour-solicitation": 135,
"nd-neighbor-advert": 136, "nd-neighbor-advert": 136,
"neighbor-advertisement": 136,
"neighbour-advertisement": 136,
"nd-redirect": 137, "nd-redirect": 137,
"redirect": 137,
"router-renumbering": 138, "router-renumbering": 138,
"ind-neighbor-solicit": 141, "ind-neighbor-solicit": 141,
"ind-neighbor-advert": 142, "ind-neighbor-advert": 142,
@ -306,6 +316,9 @@ func ParseICMPType(tok string, v6 bool) (uint8, bool) {
// GetProtocol converts a string to the network protocol. The common spellings // GetProtocol converts a string to the network protocol. The common spellings
// each backend emits for ICMPv6 (icmpv6, ipv6-icmp, icmp6) are all recognized. // each backend emits for ICMPv6 (icmpv6, ipv6-icmp, icmp6) are all recognized.
// An unknown token resolves to ProtocolAny (the widest match), so a caller that
// must distinguish an unknown protocol from a genuine "any" checks the token
// itself, as the save-file parsers do.
func GetProtocol(proto string) Protocol { func GetProtocol(proto string) Protocol {
switch { switch {
case strings.EqualFold("udp", proto): case strings.EqualFold("udp", proto):
@ -828,6 +841,10 @@ type Rule struct {
// backends. It is unexported and informational — it backs HasPrefix for the // backends. It is unexported and informational — it backs HasPrefix for the
// container backends and, like HasPrefix, is not part of rule identity. // container backends and, like HasPrefix, is not part of rule identity.
table string table string
// meterSet records the dynamic set a per-source connection-limit row counts
// in, captured on read by the nftables backend so a removal can also clear
// the set. It is unexported, derived on read, and not part of rule identity.
meterSet string
} }
// directionFromOutput maps an input/output boolean to a Direction. Backends whose // directionFromOutput maps an input/output boolean to a Direction. Backends whose
@ -888,6 +905,12 @@ func (r *Rule) HasSourcePorts() bool {
return r.SourcePort != 0 || len(r.SourcePorts) > 0 return r.SourcePort != 0 || len(r.SourcePorts) > 0
} }
// perSourceLimited reports whether the rule carries a per-source connection
// limit, the form whose count is keyed on the source address.
func (r *Rule) perSourceLimited() bool {
return r.ConnLimit != nil && r.ConnLimit.PerSource
}
// HasPortSet reports whether the rule matches more than a single discrete port // HasPortSet reports whether the rule matches more than a single discrete port
// (a list, or a range spanning more than one port). Backends limited to a single // (a list, or a range spanning more than one port). Backends limited to a single
// port use this to reject rules they cannot represent. // port use this to reject rules they cannot represent.
@ -919,8 +942,8 @@ func (r *Rule) HasSourcePortSet() bool {
// source port without a concrete port-carrying protocol (TCP/UDP/SCTP). Most // source port without a concrete port-carrying protocol (TCP/UDP/SCTP). Most
// firewall backends cannot express a port match without such a protocol, so they // firewall backends cannot express a port match without such a protocol, so they
// use this to reject such a rule rather than silently widening it (matching every // use this to reject such a rule rather than silently widening it (matching every
// protocol) or emitting an invalid rule. UFW is the exception and can express a // protocol) or emitting an invalid rule. A ported `any` in a ufw tuple is not this
// bare port across any protocol. // shape: it means tcp+udp and is modeled as TCPUDP.
func (r *Rule) PortNeedsConcreteProtocol() bool { func (r *Rule) PortNeedsConcreteProtocol() bool {
return portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) || portNeedsConcreteProtocol(r.SourcePort, r.SourcePorts, r.Proto) return portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) || portNeedsConcreteProtocol(r.SourcePort, r.SourcePorts, r.Proto)
} }
@ -1111,7 +1134,8 @@ func (r *Rule) matchFields(rule *Rule, directionHonored bool) bool {
// Equal reports whether two rules are the same. Family is compared through // Equal reports whether two rules are the same. Family is compared through
// impliedFamily so a FamilyAny rule matches the concrete family its own content // impliedFamily so a FamilyAny rule matches the concrete family its own content
// forces (an ICMP rule is IPv4, an ICMPv6 rule IPv6, an addressed rule its // forces (an ICMP rule is IPv4, an ICMPv6 rule IPv6, an addressed rule its
// address's family). // address's family). outputHonored is false on a backend with no output concept
// (Capabilities().Output), where direction does not distinguish two rules.
func (r *Rule) Equal(rule *Rule, outputHonored bool) bool { func (r *Rule) Equal(rule *Rule, outputHonored bool) bool {
if r.impliedFamily() != rule.impliedFamily() { if r.impliedFamily() != rule.impliedFamily() {
return false return false
@ -1120,19 +1144,20 @@ func (r *Rule) Equal(rule *Rule, outputHonored bool) bool {
} }
// EqualBase reports whether two rules are the same, ignoring family. // EqualBase reports whether two rules are the same, ignoring family.
// outputHonored has Equal's meaning.
func (r *Rule) EqualBase(rule *Rule, outputHonored bool) bool { func (r *Rule) EqualBase(rule *Rule, outputHonored bool) bool {
return r.matchFields(rule, outputHonored) return r.matchFields(rule, outputHonored)
} }
// coversDirection reports whether an existing rule in direction have already // coversDirection reports whether an existing rule's direction (have) already
// covers a caller rule in direction want (the asymmetric add/dedup form). DirAny // covers a caller rule's direction (want) — the asymmetric add/dedup form. DirAny
// spans input and output, so it covers either; it never covers DirForward (a // spans input and output, so it covers either; it never covers DirForward (a
// routed rule has no in/out twin). When outputHonored is false the backend has no // routed rule has no in/out twin). When outputHonored is false the backend has no
// output concept, so direction never distinguishes two rules and coverage is // output concept, so input and output never distinguish two rules — but a
// unconditional — behavior identical to the pre-DirAny code. // forward rule is still its own chain and only covered by another forward rule.
func coversDirection(have, want Direction, outputHonored bool) bool { func coversDirection(have, want Direction, outputHonored bool) bool {
if !outputHonored { if !outputHonored {
return true return (have == DirForward) == (want == DirForward)
} }
if have == want { if have == want {
return true return true
@ -1145,7 +1170,7 @@ func coversDirection(have, want Direction, outputHonored bool) bool {
// it touches any concrete direction and vice versa. DirForward stands alone. // it touches any concrete direction and vice versa. DirForward stands alone.
func coversDirectionRemoval(a, b Direction, outputHonored bool) bool { func coversDirectionRemoval(a, b Direction, outputHonored bool) bool {
if !outputHonored { if !outputHonored {
return true return (a == DirForward) == (b == DirForward)
} }
if a == b { if a == b {
return true return true
@ -1160,8 +1185,8 @@ func coversDirectionRemoval(a, b Direction, outputHonored bool) bool {
} }
// EqualForDedup reports whether the receiver (an existing rule) already makes o // EqualForDedup reports whether the receiver (an existing rule) already makes o
// redundant on add: the same base rule, and the receiver's family and direction // redundant on add: the same base rule, with the receiver's family, direction
// both cover o's. It is the family- and direction-aware add guard the container // and transport (a TCPUDP row covers its tcp/udp halves) all covering o's. It is the family- and direction-aware add guard the container
// backends need because EqualBase ignores Family — without the coverage check, // backends need because EqualBase ignores Family — without the coverage check,
// adding an IPv6 rule whose IPv4 twin already exists would be dropped as a false // adding an IPv6 rule whose IPv4 twin already exists would be dropped as a false
// duplicate, leaving that family unprotected. Family and direction are checked // duplicate, leaving that family unprotected. Family and direction are checked
@ -1175,7 +1200,7 @@ func (r *Rule) EqualForDedup(o *Rule, outputHonored bool) bool {
return r.covers(o, outputHonored) return r.covers(o, outputHonored)
} }
// coversFamily reports whether an existing rule's family have already covers a caller // 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 // 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 // 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. // address- or ICMP-pinned rule is compared by the family it actually targets.
@ -1222,16 +1247,16 @@ func (r *Rule) Covers(o *Rule) bool {
// 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, transport and // on when the caller targets o: the same base rule, and o's family, transport and
// direction each touch the row's. It is the overlap relation a removal walks the // direction each touch the row's. It is the overlap relation a removal walks the
// stored rows with, so a target removes every row it covers (rule 3) and also // stored rows with: a target removes every row it covers, and also matches a row
// matches a row that covers more than the target, which the backend then deletes and // that covers more than the target, which the backend then deletes and re-adds
// re-adds minus the targeted cell (splitMergedRow, rule 4). A FamilyAny/TCPUDP/DirAny // minus the targeted cell (splitMergedRow). A FamilyAny/TCPUDP/DirAny target
// target matches every row on that axis, such a row matches any target, and otherwise // matches every row on that axis, such a row matches any target, and otherwise
// the values must match so acting on one twin never disturbs the other. Family and // the values must match so acting on one twin never disturbs the other. The
// direction are checked first so a non-matching row skips the field compare, which // field compare runs in the inbound frame (canonicalMatch) with direction
// runs in the inbound frame (canonicalMatch) with direction excluded. // excluded, since the axis gates above already decided it.
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 {
return false return false
} }
if !coversDirectionRemoval(r.Direction, o.Direction, outputHonored) { if !coversDirectionRemoval(r.Direction, o.Direction, outputHonored) {
@ -1280,6 +1305,21 @@ func splitDualRow(matched, target *Rule) *Rule {
return &opp return &opp
} }
// splitNATDualRow is splitDualRow's NAT analog: the copy of a genuine
// dual-family NAT row (a single stored translation with no family pin) that a
// backend re-adds after deleting it for a concrete-family removal, pinned to
// the family the caller did NOT target. It returns nil when no split applies.
// Family is the only axis a NAT rule spans.
func splitNATDualRow(matched, target *NATRule) *NATRule {
tf := target.impliedFamily()
if tf == FamilyAny || matched.impliedFamily() != FamilyAny {
return nil
}
opp := *matched
opp.Family = oppositeFamily(tf)
return &opp
}
// 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
@ -1428,7 +1468,7 @@ func splitMergedRow(matched, target *Rule) []*Rule {
return out return out
} }
// coversProtocol reports whether an existing rule's protocol have already covers a // 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 // 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 // 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. // not a merged value — it matches every IP protocol — so it covers only ProtocolAny.
@ -1506,22 +1546,6 @@ func splitDualRowDirection(matched, target *Rule) *Rule {
} }
} }
// rejectLogAndLimit reports a logging or rate/connection-limit request on a
// backend whose per-rule model cannot express it. Such a rule is rejected
// rather than applied without the modifier. It returns nil when the rule asks
// for none of these. backend names the backend for the error message.
func (r *Rule) rejectLogAndLimit(backend string) error {
switch {
case r.Log:
return fmt.Errorf("%s does not support per-rule logging in this model: %w", backend, ErrUnsupportedLog)
case r.RateLimit != nil:
return fmt.Errorf("%s does not support rate limiting in this model: %w", backend, ErrUnsupportedRateLimit)
case r.ConnLimit != nil:
return fmt.Errorf("%s does not support connection limiting in this model: %w", backend, ErrUnsupportedConnLimit)
}
return nil
}
// unsupportedNAT is the error a backend returns from its NAT methods when its // unsupportedNAT is the error a backend returns from its NAT methods when its
// model cannot express network address translation. backend names the backend. // model cannot express network address translation. backend names the backend.
func unsupportedNAT(backend string) error { func unsupportedNAT(backend string) error {
@ -1661,8 +1685,10 @@ func (k NATKind) String() string {
return "invalid" return "invalid"
} }
// ParseNATKind parses a NAT-kind token (case-insensitive), accepting the canonical // ParseNATKind parses a NAT-kind token (case-insensitive), accepting only the
// name emitted by NATKind.String. An unknown value is an error. // concrete kinds NATKind.String emits. The sentinel "invalid" (NATInvalid) is
// rejected, mirroring ParseAction, so callers cannot author a NAT rule with no
// real kind; backup decoding round-trips it separately in NATKind.UnmarshalJSON.
func ParseNATKind(s string) (NATKind, error) { func ParseNATKind(s string) (NATKind, error) {
switch strings.ToLower(strings.TrimSpace(s)) { switch strings.ToLower(strings.TrimSpace(s)) {
case "dnat": case "dnat":
@ -1673,8 +1699,6 @@ func ParseNATKind(s string) (NATKind, error) {
return SNAT, nil return SNAT, nil
case "masquerade": case "masquerade":
return Masquerade, nil return Masquerade, nil
case "invalid":
return NATInvalid, nil
} }
return 0, fmt.Errorf("unknown nat kind %q", s) return 0, fmt.Errorf("unknown nat kind %q", s)
} }
@ -2065,6 +2089,11 @@ type Capabilities struct {
// PortList is true when multi-port (comma list) matching is honored. (Port // PortList is true when multi-port (comma list) matching is honored. (Port
// ranges are supported by every backend, so they are not advertised.) // ranges are supported by every backend, so they are not advertised.)
PortList bool PortList bool
// PortPair is true when a source-port match can be combined with a
// destination-port match in one rule. A false PortPair means such a rule is
// rejected with ErrUnsupportedSourcePort (a firewalld rich rule carries a
// single port element).
PortPair bool
// ConnState is true when connection-tracking state can be matched. // ConnState is true when connection-tracking state can be matched.
ConnState bool ConnState bool
// InterfaceMatch is true when a rule can bind to a per-rule interface (as // InterfaceMatch is true when a rule can bind to a per-rule interface (as
@ -2089,6 +2118,28 @@ type Capabilities struct {
// AddRule and populated by GetRules. A false Comments means the backend // AddRule and populated by GetRules. A false Comments means the backend
// silently ignores the Comment field. // silently ignores the Comment field.
Comments bool Comments bool
// Negation is true when a "!"-negated Source/Destination match is honored.
// A false Negation means such a rule is rejected with ErrUnsupported (WFP
// has no negated address condition).
Negation bool
// RejectAction is true when the Reject action — refuse with an error
// response, as opposed to a silent Drop — is expressible. A false
// RejectAction means a Reject rule is rejected with ErrUnsupported (WFP
// only permits or blocks).
RejectAction bool
// FamilyWithoutAddress is true when a rule with a concrete Family but no
// Source/Destination is expressible. A false value means family scoping
// rides on addresses alone and such a rule is rejected with ErrUnsupported
// (a WFP filter scopes family through its address conditions).
FamilyWithoutAddress bool
// DenyActionFromConfig is true when the backend's native deny store carries
// no per-entry action — the firewall tool applies the deny action its own
// config names (csf.conf DROP, conf.apf ALL_STOP). A deny added with the
// config's action is stored natively and reads back with it; one with a
// differing action is expressed elsewhere (the raw-iptables hook). Because
// the native entry encodes no action, RemoveRule clears it whatever action
// the removal target names.
DenyActionFromConfig bool
} }
// The backend type strings Manager.Type reports, one per backend. They are declared // The backend type strings Manager.Type reports, one per backend. They are declared
@ -2171,7 +2222,8 @@ type Manager interface {
// cannot manage a default policy return an unsupported error. // cannot manage a default policy return an unsupported error.
SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error
// GetAddressSets returns the address sets managed by this backend. // GetAddressSets returns the address sets managed by this backend. Backends
// that cannot manage address sets return an unsupported error.
GetAddressSets(ctx context.Context) ([]*AddressSet, error) GetAddressSets(ctx context.Context) ([]*AddressSet, error)
// GetAddressSet returns a single address set by name, or an error if it does // GetAddressSet returns a single address set by name, or an error if it does
@ -2326,12 +2378,24 @@ func Sync(ctx context.Context, mgr Manager, zoneName string, desired []*Rule) (a
} }
toAdd = append(toAdd, d) toAdd = append(toAdd, d)
} }
if err := AddRules(ctx, mgr, zoneName, toAdd); err != nil { // Count additions as they land so the reported added stays accurate on a
// mid-batch failure: a RuleBatcher applies all-or-nothing, while the
// per-rule fallback may have added several rules before erroring.
if b, ok := mgr.(RuleBatcher); ok {
if err := b.AddRulesBatch(ctx, zoneName, toAdd); err != nil {
return added, removed, err return added, removed, err
} }
added = len(toAdd) added = len(toAdd)
return added, removed, nil return added, removed, nil
} }
for _, r := range toAdd {
if err := mgr.AddRule(ctx, zoneName, r); err != nil {
return added, removed, err
}
added++
}
return added, removed, nil
}
// unsupportedOrdering is returned by backends that do not support explicit // unsupportedOrdering is returned by backends that do not support explicit
// rule ordering for InsertRule or MoveRule. // rule ordering for InsertRule or MoveRule.

View file

@ -1689,3 +1689,45 @@ func TestNATCoveredBy(t *testing.T) {
require.True(t, dnatAny.CoveredBy([]*NATRule{dnatV4}), require.True(t, dnatAny.CoveredBy([]*NATRule{dnatV4}),
"the translation address already pins this rule to IPv4") "the translation address already pins this rule to IPv4")
} }
// coversDirection with outputHonored=false collapses input/output but must not
// let an input row cover a DirForward target: forward is its own chain even on a
// backend with no output concept.
func TestCoversDirectionForwardIsolated(t *testing.T) {
require.True(t, coversDirection(DirInput, DirOutput, false))
require.True(t, coversDirection(DirOutput, DirInput, false))
require.False(t, coversDirection(DirInput, DirForward, false))
require.False(t, coversDirection(DirForward, DirInput, false))
require.True(t, coversDirection(DirForward, DirForward, false))
require.False(t, coversDirectionRemoval(DirInput, DirForward, false))
require.True(t, coversDirectionRemoval(DirForward, DirForward, false))
}
// The ip6tables/ufw ICMPv6 type spellings resolve alongside the nftables names.
func TestICMPv6NameAliases(t *testing.T) {
cases := map[string]uint8{
"router-solicitation": 133,
"router-advertisement": 134,
"neighbor-solicitation": 135,
"neighbour-solicitation": 135,
"neighbor-advertisement": 136,
"neighbour-advertisement": 136,
"redirect": 137,
"nd-redirect": 137,
}
for name, want := range cases {
n, ok := parseICMPTypeFamily(name, true)
require.True(t, ok, "name %q must resolve", name)
require.Equal(t, want, n, "name %q", name)
}
}
// ParseNATKind rejects the "invalid" sentinel as caller input while the JSON
// round-trip still accepts it, mirroring Action.
func TestParseNATKindRejectsSentinel(t *testing.T) {
_, err := ParseNATKind("invalid")
require.Error(t, err)
var k NATKind
require.NoError(t, k.UnmarshalJSON([]byte(`"invalid"`)))
require.Equal(t, NATInvalid, k)
}

View file

@ -11,6 +11,16 @@ import (
firewalld "github.com/grmrgecko/go-firewalld" firewalld "github.com/grmrgecko/go-firewalld"
) )
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager
// interface onto firewalld's zones, rich rules, and ipsets.
type FirewallD struct {
Conn *firewalld.Conn
// rulePrefix is accepted for a consistent constructor signature across
// backends. firewalld organizes rules into zones rather than a private
// namespace, so the prefix is not applied to individual rules.
rulePrefix string
}
// NewFirewallD connects to firewalld and returns a manager, or an error when // 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) {
@ -40,6 +50,9 @@ func (f *FirewallD) Capabilities() Capabilities {
Priority: true, Priority: true,
ICMPv6: true, ICMPv6: true,
PortList: false, PortList: false,
// A rich rule carries a single port element, so a source-port match
// cannot be combined with a destination-port match in one rule.
PortPair: false,
ConnState: false, ConnState: false,
InterfaceMatch: false, InterfaceMatch: false,
Logging: true, Logging: true,
@ -50,6 +63,9 @@ func (f *FirewallD) Capabilities() Capabilities {
DefaultPolicy: true, DefaultPolicy: true,
RuleCounters: false, RuleCounters: false,
AddressSets: true, AddressSets: true,
Negation: true,
RejectAction: true,
FamilyWithoutAddress: true,
} }
} }
@ -59,21 +75,19 @@ func (f *FirewallD) GetZone(ctx context.Context, iface string) (zoneName string,
// Ask firewalld directly which permanent zone the interface is bound to. // Ask firewalld directly which permanent zone the interface is bound to.
// This returns the zone id (e.g. "public"), which is what the other backend // This returns the zone id (e.g. "public"), which is what the other backend
// methods expect to pass back into Permanent().Zone. An empty result or an // methods expect to pass back into Permanent().Zone. An empty result or an
// error means the interface is not bound to a zone, so we fall through to // error means the interface is not bound to a zone, leaving the default
// the default zone below. // zone below.
zoneName, err = f.Conn.Permanent().ZoneOfInterface(ctx, iface) zoneName, err = f.Conn.Permanent().ZoneOfInterface(ctx, iface)
if err == nil && zoneName != "" { if err == nil && zoneName != "" {
return zoneName, nil return zoneName, nil
} }
// If we did not find a zone for the specified interface, use the default // An unbound interface belongs to the default zone.
// zone if it exists.
defaultZone, derr := f.Conn.DefaultZone(ctx) defaultZone, derr := f.Conn.DefaultZone(ctx)
if derr == nil && defaultZone != "" { if derr == nil && defaultZone != "" {
return defaultZone, nil return defaultZone, nil
} }
// If we were unable to find the zone or a default zone, error out.
return "", fmt.Errorf("unable to find zone") return "", fmt.Errorf("unable to find zone")
} }
@ -225,7 +239,10 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
} }
// Parse the destination, which firewalld expresses as either an address // Parse the destination, which firewalld expresses as either an address
// or an ipset (mirroring the source grammar). // or an ipset (mirroring the source grammar). firewalld has no output
// chain, so a destination element is an inbound-frame destination match,
// not a direction signal — the write side stores every rule in the
// inbound frame (see AddRule).
if strings.HasPrefix(tokens[i], "address=") { if strings.HasPrefix(tokens[i], "address=") {
address := trimQuotes(strings.TrimPrefix(tokens[i], "address=")) address := trimQuotes(strings.TrimPrefix(tokens[i], "address="))
if not { if not {
@ -233,7 +250,6 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
} else { } else {
r.Destination = address r.Destination = address
} }
r.Direction = DirOutput
} else if strings.HasPrefix(tokens[i], "ipset=") { } else if strings.HasPrefix(tokens[i], "ipset=") {
ipset := trimQuotes(strings.TrimPrefix(tokens[i], "ipset=")) ipset := trimQuotes(strings.TrimPrefix(tokens[i], "ipset="))
if not { if not {
@ -241,7 +257,6 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
} else { } else {
r.Destination = ipset r.Destination = ipset
} }
r.Direction = DirOutput
} else { } else {
return nil, fmt.Errorf("the destination argument has no address or ipset defined") return nil, fmt.Errorf("the destination argument has no address or ipset defined")
} }
@ -257,6 +272,13 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
} }
i++ i++
} }
// A limit element directly after log throttles only the logging, which
// RateLimit cannot hold — it models a traffic limit. Reading it as one
// would let a rewrite turn a log throttle into a traffic throttle, so
// the rule stays unmodeled.
if i+1 < len(tokens) && tokens[i+1] == "limit" {
return nil, fmt.Errorf("a log-scoped limit is unsupported")
}
} else if tokens[i] == "limit" { } else if tokens[i] == "limit" {
// A rule-level rate limit: limit value="N/unit" where unit is one of // A rule-level rate limit: limit value="N/unit" where unit is one of
// s/m/h/d. Parse it into the rule's RateLimit. // s/m/h/d. Parse it into the rule's RateLimit.
@ -380,11 +402,17 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
// An icmp-type element restricts to a single ICMP message type by // An icmp-type element restricts to a single ICMP message type by
// firewalld name, e.g. icmp-type name="echo-request". The numeric type // firewalld name, e.g. icmp-type name="echo-request". The numeric type
// and the ICMP protocol both depend on the rule's family, which appears // and the ICMP protocol both depend on the rule's family, which appears
// earlier in the rule string, so r.Family is already set here. // earlier in the rule string, so r.Family is already set here. A
// familyless icmp-type rule (foreign; firewalld applies it to both
// families) has no single ICMP/ICMPv6 form, so it stays unmodeled
// rather than reading back as its IPv4 half.
i++ i++
if i >= len(tokens) || !strings.HasPrefix(tokens[i], "name=") { if i >= len(tokens) || !strings.HasPrefix(tokens[i], "name=") {
return nil, fmt.Errorf("the icmp-type element has no defined name") return nil, fmt.Errorf("the icmp-type element has no defined name")
} }
if r.Family == FamilyAny {
return nil, fmt.Errorf("an icmp-type element without a family is unsupported")
}
isV6 := r.Family == IPv6 isV6 := r.Family == IPv6
num, ok := f.icmpTypeNumber(isV6, trimQuotes(strings.TrimPrefix(tokens[i], "name="))) num, ok := f.icmpTypeNumber(isV6, trimQuotes(strings.TrimPrefix(tokens[i], "name=")))
if !ok { if !ok {
@ -649,7 +677,7 @@ func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) {
return "", fmt.Errorf("firewalld rich rules cannot match a destination and source port together: %w", ErrUnsupportedSourcePort) return "", fmt.Errorf("firewalld rich rules cannot match a destination and source port together: %w", ErrUnsupportedSourcePort)
} }
// An ICMP type only applies to an ICMP/ICMPv6 protocol; reject it paired with // An ICMP type only applies to an ICMP/ICMPv6 protocol; reject it paired with
// anything else before we try to render an icmp-type element. // anything else before rendering an icmp-type element.
if err := r.checkICMPType(); err != nil { if err := r.checkICMPType(); err != nil {
return "", err return "", err
} }
@ -719,7 +747,7 @@ func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) {
// is no address to infer a family from — reject rather than emit a rule // is no address to infer a family from — reject rather than emit a rule
// firewalld refuses with MISSING_FAMILY. // firewalld refuses with MISSING_FAMILY.
if _, _, err := net.ParseCIDR(dst); err != nil && net.ParseIP(dst) == nil { if _, _, err := net.ParseCIDR(dst); err != nil && net.ParseIP(dst) == nil {
return "", fmt.Errorf("firewalld requires an explicit Family for a destination ipset match") return "", fmt.Errorf("firewalld requires an explicit Family for a destination ipset match: %w", ErrUnsupportedSet)
} }
} }
@ -868,11 +896,48 @@ func (f *FirewallD) zoneEntryEligible(r *Rule) bool {
// AddRule adds a filter rule to a zone, using a zone-level entry when the rule // AddRule adds a filter rule to a zone, using a zone-level entry when the rule
// fits one and a rich rule otherwise. // fits one and a rich rule otherwise.
// resolveDestSetFamily pins a family-agnostic rule whose destination names an
// ipset to the set's own family: firewalld requires an explicit family for a
// destination ipset match (unlike a source one, which it accepts familyless),
// and an ipset is family-typed, so the rule could only ever match that family.
// It is the firewalld analog of the nft and hook layers' set-family resolution.
// Rules pinned already, or carrying an address that implies a family, pass
// through unchanged.
func (f *FirewallD) resolveDestSetFamily(ctx context.Context, r *Rule) (*Rule, error) {
if r.Family != FamilyAny || !isSetRef(r.Destination) {
return r, nil
}
if familyOfAddr(r.Source) != FamilyAny {
return r, nil
}
_, name := splitAddrNeg(strings.TrimSpace(r.Destination))
set, err := f.getAddressSet(ctx, name)
if err != nil {
return nil, err
}
if set == nil {
return nil, fmt.Errorf("rule references unknown address set %q", name)
}
fam := set.Family
if fam != IPv6 {
fam = IPv4
}
rc := *r
rc.Family = fam
return &rc, nil
}
func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error { func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error {
// firewalld has no output chain (Capabilities().Output is false); it models a // firewalld has no output chain (Capabilities().Output is false); it models a
// "destination" match rather than a true outbound direction. A both-directions // "destination" match rather than a true outbound direction. A both-directions
// DirAny rule cannot be expressed, so it degrades to its input half. // DirAny rule cannot be expressed, so it degrades to its input half, and a
// DirOutput rule is stored in the inbound frame (role-swapped) so both
// authorings of the same match converge on one stored form and read back
// identically — otherwise Sync would remove and re-add it forever.
r = dirAnyInputFallback(r, f.Capabilities().Output) r = dirAnyInputFallback(r, f.Capabilities().Output)
if r.Direction == DirOutput {
r = r.canonicalMatch()
}
// A firewalld zone port and rich rule each carry a single protocol, so a // 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 // TCPUDP rule has no single form; fan it into a tcp add and a udp add, each of
@ -891,8 +956,14 @@ func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error
return fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit) return fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit)
} }
// A family-agnostic destination set reference is pinned to the set's own family.
r, err := f.resolveDestSetFamily(ctx, r)
if err != nil {
return err
}
// Get the zone. // Get the zone.
zoneName, err := f.resolveZoneName(ctx, zoneName) zoneName, err = f.resolveZoneName(ctx, zoneName)
if err != nil { if err != nil {
return err return err
} }
@ -970,18 +1041,27 @@ func (f *FirewallD) ignoreNotEnabled(err error) error {
return err return err
} }
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager // removeZoneEntry removes the zone-level entry — a zone port, source port, or
// interface onto firewalld's zones, rich rules, and ipsets. // protocol — that a zone-entry-shaped rule maps to, reporting whether the rule's
type FirewallD struct { // shape matched one of those forms. The firewalld error (including NOT_ENABLED)
Conn *firewalld.Conn // is returned uninterpreted; RemoveRule's zone-entry shortcut and its dual-stack
// rulePrefix is accepted for a consistent constructor signature across // split share this so their form routing cannot drift apart.
// backends. firewalld organizes rules into zones rather than a private func (f *FirewallD) removeZoneEntry(ctx context.Context, zone *firewalld.PermZone, r *Rule) (bool, error) {
// namespace, so the prefix is not applied to individual rules. switch {
rulePrefix string case r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts():
return true, zone.RemovePort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()})
case r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "":
return true, zone.RemoveSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()})
case r.Proto != ProtocolAny && r.Source == "" && !r.HasPorts() && !r.HasSourcePorts():
return true, zone.RemoveProtocol(ctx, r.Proto.String())
}
return false, nil
} }
// isZoneSource reports whether a source string is a form firewalld stores as a // isZoneSource reports whether a source string is a form firewalld stores as a
// zone source: an IP, a CIDR, a MAC address, or an ipset reference (ipset:<name>). // zone source: an IP, a CIDR, a MAC address, or an ipset reference (ipset:<name>).
// RemoveRule uses it to decide whether a bare source can be cleared with
// RemoveSource rather than falling through to the rich-rule path.
func (f *FirewallD) isZoneSource(s string) bool { func (f *FirewallD) isZoneSource(s string) bool {
if _, _, err := net.ParseCIDR(s); err == nil { if _, _, err := net.ParseCIDR(s); err == nil {
return true return true
@ -998,12 +1078,17 @@ func (f *FirewallD) isZoneSource(s string) bool {
return false return false
} }
// RemoveRule uses it to decide whether a bare source can be cleared with // RemoveRule removes a filter rule from a zone, clearing every form it may be
// RemoveSource rather than falling through to the rich-rule path. // stored in: the zone-level entry AddRule would have used and any rich rule
// whose parsed form matches the target.
func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
// A DirAny rule degrades to its input half on firewalld (no output concept), // A DirAny rule degrades to its input half on firewalld (no output concept),
// mirroring AddRule so a both-directions rule is found and removed as stored. // and a DirOutput target is role-swapped into the inbound frame, mirroring
// AddRule so a rule is found and removed as stored.
r = dirAnyInputFallback(r, f.Capabilities().Output) r = dirAnyInputFallback(r, f.Capabilities().Output)
if r.Direction == DirOutput {
r = r.canonicalMatch()
}
// firewalld stores a TCPUDP rule as two concrete rows (a tcp entry and a udp // 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 // entry), never one shared row, so such a removal fans into a tcp remove and a
@ -1030,45 +1115,23 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
// so they are handled (ICMP) or rejected there rather than silently dropped by // so they are handled (ICMP) or rejected there rather than silently dropped by
// the port/source shortcuts below. Logging and rate limiting also force the // the port/source shortcuts below. Logging and rate limiting also force the
// rich-rule path. Each shortcut removes the element directly and relies on // rich-rule path. Each shortcut removes the element directly and relies on
// firewalld's typed errors: a real error is returned, but NOT_ENABLED (the // firewalld's typed errors: a real error is returned, while success and
// element is absent as a zone entry) falls through to the rich-rule path in // NOT_ENABLED both continue to the rich-rule sweep — a rule present in both
// case the rule was stored in that form out of band. // storage forms (a zone entry plus an equivalent rich rule added out of band)
// must be cleared from both in one call.
if f.zoneEntryEligible(r) { if f.zoneEntryEligible(r) {
// A single destination port (or contiguous range) maps to a zone port entry. if matched, rerr := f.removeZoneEntry(ctx, zone, r); matched {
// Mirror AddRule: key on HasPorts (so a single port in the Ports slice if rerr != nil && !errors.Is(rerr, firewalld.ErrNotEnabled) {
// matches) and require a concrete port protocol. return rerr
if r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts() {
port := firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()}
if err := zone.RemovePort(ctx, port); !errors.Is(err, firewalld.ErrNotEnabled) {
return err
} }
} else if r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "" { } else if f.sourceZoneShape(r) && f.isZoneSource(r.Source) {
// A single source port maps to a zone source-port entry.
sp := firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()}
if err := zone.RemoveSourcePort(ctx, sp); !errors.Is(err, firewalld.ErrNotEnabled) {
return err
}
} else if f.sourceZoneShape(r) {
// A bare source maps to a zone source. firewalld stores an IP/CIDR, a MAC // A bare source maps to a zone source. firewalld stores an IP/CIDR, a MAC
// address, or an ipset reference (ipset:<name>) as a zone source, and // address, or an ipset reference (ipset:<name>) as a zone source, and
// GetRules reports each verbatim, so removal must try RemoveSource for all // GetRules reports each verbatim, so removal must try RemoveSource for all
// of them. A value that is not a zone source form falls through to the // of them. A value that is not a zone source form is left to the rich-rule
// rich-rule path below. // path below; sourceZoneShape mirrors AddRule's ProtocolAny guard, since a
// sourceZoneShape mirrors AddRule's ProtocolAny guard: a source+protocol // source+protocol rule is stored as a rich rule.
// rule is a rich rule, so it must be removed as one rather than clearing if err := zone.RemoveSource(ctx, r.Source); err != nil && !errors.Is(err, firewalld.ErrNotEnabled) {
// the bare zone source.
if f.isZoneSource(r.Source) {
if err := zone.RemoveSource(ctx, r.Source); !errors.Is(err, firewalld.ErrNotEnabled) {
return err
}
}
} else if r.Proto != ProtocolAny && r.Source == "" && !r.HasPorts() && !r.HasSourcePorts() {
// A bare protocol allow (no port or address) maps to a zone protocol
// entry (firewall-cmd --add-protocol), which GetRules now surfaces.
// Mirror the port/source shortcuts so such a foreign entry is removable;
// a NOT_ENABLED result falls through to the rich-rule path for a rule the
// library stored as a rich-rule protocol match instead.
if err := zone.RemoveProtocol(ctx, r.Proto.String()); !errors.Is(err, firewalld.ErrNotEnabled) {
return err return err
} }
} }
@ -1088,19 +1151,7 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
rAny := *r rAny := *r
rAny.Family = FamilyAny rAny.Family = FamilyAny
if f.zoneEntryEligible(&rAny) && r.Source == "" { if f.zoneEntryEligible(&rAny) && r.Source == "" {
var removeErr error if matched, removeErr := f.removeZoneEntry(ctx, zone, r); matched {
matched := true
switch {
case r.HasPorts() && r.Proto.HasPorts() && !r.HasSourcePorts():
removeErr = zone.RemovePort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()})
case r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts():
removeErr = zone.RemoveSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()})
case r.Proto != ProtocolAny && !r.HasPorts() && !r.HasSourcePorts():
removeErr = zone.RemoveProtocol(ctx, r.Proto.String())
default:
matched = false
}
if matched {
if removeErr == nil { if removeErr == nil {
if s := splitDualRow(&rAny, r); s != nil { if s := splitDualRow(&rAny, r); s != nil {
return f.AddRule(ctx, zoneName, s) return f.AddRule(ctx, zoneName, s)
@ -1128,7 +1179,7 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
if r.Proto == ICMPv6 { if r.Proto == ICMPv6 {
proto = "ipv6-icmp" proto = "ipv6-icmp"
} }
if err := zone.RemoveProtocol(ctx, proto); !errors.Is(err, firewalld.ErrNotEnabled) { if err := zone.RemoveProtocol(ctx, proto); err != nil && !errors.Is(err, firewalld.ErrNotEnabled) {
return err return err
} }
} }
@ -1187,7 +1238,7 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
return nil, err return nil, err
} }
// Get the zone settings so we can read forward ports and masquerade. // Read the zone settings for forward ports and masquerade.
settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx) settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1195,33 +1246,13 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
// Each forward port maps to a DNAT (when it targets another address) or a // Each forward port maps to a DNAT (when it targets another address) or a
// Redirect (same host, port only). firewalld's model carries no family, so // Redirect (same host, port only). firewalld's model carries no family, so
// these are returned as FamilyAny. // these are returned as FamilyAny. An entry the model cannot hold stays
// unmanaged (see modeledForwardPort).
for _, fp := range settings.ForwardPorts { for _, fp := range settings.ForwardPorts {
pr, perr := ParsePortRange(fp.Port) if rule := f.modeledForwardPort(fp); rule != nil {
if perr != nil {
continue
}
rule := &NATRule{Proto: GetProtocol(fp.Protocol)}
if pr.Start == pr.End {
rule.Port = pr.Start
} else {
rule.Ports = []PortRange{pr}
}
if fp.ToPort != "" {
tp, terr := strconv.ParseUint(fp.ToPort, 10, 16)
if terr != nil {
continue
}
rule.ToPort = uint16(tp)
}
rule.ToAddress = fp.ToAddr
if fp.ToAddr != "" {
rule.Kind = DNAT
} else {
rule.Kind = Redirect
}
rules = append(rules, rule) rules = append(rules, rule)
} }
}
// Zone masquerade maps to a single Masquerade rule. // Zone masquerade maps to a single Masquerade rule.
if settings.Masquerade { if settings.Masquerade {
@ -1237,6 +1268,43 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
return rules, nil return rules, nil
} }
// modeledForwardPort decodes a zone forward-port entry into the NATRule
// GetNATRules reports, or nil for an entry the model cannot hold: an
// unparseable port, a protocol the write side rejects (sctp/dccp — forwardPort
// takes tcp/udp only, so such a rule could never be removed or re-added), or a
// target-port range. An unmodeled entry stays unmanaged, and Restore leaves it
// in place for the same reason.
func (f *FirewallD) modeledForwardPort(fp firewalld.ForwardPort) *NATRule {
pr, perr := ParsePortRange(fp.Port)
if perr != nil {
return nil
}
proto := GetProtocol(fp.Protocol)
if proto != TCP && proto != UDP {
return nil
}
rule := &NATRule{Proto: proto}
if pr.Start == pr.End {
rule.Port = pr.Start
} else {
rule.Ports = []PortRange{pr}
}
if fp.ToPort != "" {
tp, terr := strconv.ParseUint(fp.ToPort, 10, 16)
if terr != nil {
return nil
}
rule.ToPort = uint16(tp)
}
rule.ToAddress = fp.ToAddr
if fp.ToAddr != "" {
rule.Kind = DNAT
} else {
rule.Kind = Redirect
}
return rule
}
// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's // forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's
// per-zone port-forward API expects (port, protocol, toport, toaddr). That API // per-zone port-forward API expects (port, protocol, toport, toaddr). That API
// carries only these four fields, so any source, destination or interface match — // carries only these four fields, so any source, destination or interface match —
@ -1246,20 +1314,20 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
// would not round-trip, so it is intentionally not emitted here.) // would not round-trip, so it is intentionally not emitted here.)
func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) { func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) {
if r.Proto != TCP && r.Proto != UDP { if r.Proto != TCP && r.Proto != UDP {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol") return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol: %w", ErrUnsupportedNAT)
} }
if !r.HasPorts() { if !r.HasPorts() {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port") return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port: %w", ErrUnsupportedNAT)
} }
specs := r.PortSpecs() specs := r.PortSpecs()
if len(specs) > 1 { if len(specs) > 1 {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward") return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward: %w", ErrUnsupportedNAT)
} }
if r.Interface != "" { if r.Interface != "" {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface") return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface: %w", ErrUnsupportedNAT)
} }
if r.Source != "" || r.Destination != "" { if r.Source != "" || r.Destination != "" {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching") return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching: %w", ErrUnsupportedNAT)
} }
fp := firewalld.ForwardPort{ fp := firewalld.ForwardPort{
Port: specs[0].String(), Port: specs[0].String(),
@ -1401,7 +1469,10 @@ func (f *FirewallD) SetDefaultPolicy(ctx context.Context, zoneName string, polic
// --- address sets (firewalld ipsets) ---------------------------------------- // --- address sets (firewalld ipsets) ----------------------------------------
// getAddressSet reads a single firewalld ipset, or nil if it does not exist. // getAddressSet reads a single firewalld ipset, or nil if it does not exist or
// has a type the model cannot hold. Only hash:ip and hash:net map onto SetType;
// coercing another type (hash:mac, hash:ip,port, ...) would let a Backup/Restore
// rewrite the set as a bare hash:ip and break it, so those stay unmanaged.
func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) { func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx) settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx)
if errors.Is(err, firewalld.ErrInvalidIPSet) { if errors.Is(err, firewalld.ErrInvalidIPSet) {
@ -1410,9 +1481,14 @@ func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet
if err != nil { if err != nil {
return nil, err return nil, err
} }
set := &AddressSet{Name: name, Entries: settings.Entries, Type: SetHashIP} set := &AddressSet{Name: name, Entries: settings.Entries}
if settings.Type == "hash:net" { switch settings.Type {
case "hash:ip":
set.Type = SetHashIP
case "hash:net":
set.Type = SetHashNet set.Type = SetHashNet
default:
return nil, nil
} }
switch settings.Options["family"] { switch settings.Options["family"] {
case "inet6": case "inet6":
@ -1568,13 +1644,22 @@ func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup
return err return err
} }
// Remove all ports, sources, and rich rules that we can decode as managed. // Remove the zone entries and rich rules the model manages. Each removal
// mirrors the guard its read side applies, so an entry GetRules skips as
// unmodeled (a dccp port, an unrecognized protocol, an unparseable rich rule)
// is preserved rather than destroyed by a Backup that never captured it.
for _, port := range settings.Ports { for _, port := range settings.Ports {
if len(f.zonePortRules([]firewalld.Port{port}, false)) == 0 {
continue
}
if err := zone.RemovePort(ctx, port); err != nil { if err := zone.RemovePort(ctx, port); err != nil {
return err return err
} }
} }
for _, sp := range settings.SourcePorts { for _, sp := range settings.SourcePorts {
if len(f.zonePortRules([]firewalld.Port{sp}, true)) == 0 {
continue
}
if err := zone.RemoveSourcePort(ctx, sp); err != nil { if err := zone.RemoveSourcePort(ctx, sp); err != nil {
return err return err
} }
@ -1585,18 +1670,28 @@ func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup
} }
} }
for _, proto := range settings.Protocols { for _, proto := range settings.Protocols {
if GetProtocol(proto) == ProtocolAny {
continue
}
if err := zone.RemoveProtocol(ctx, proto); err != nil { if err := zone.RemoveProtocol(ctx, proto); err != nil {
return err return err
} }
} }
for _, richRule := range settings.RichRules { for _, richRule := range settings.RichRules {
if _, perr := f.UnmarshalRichRule(richRule); perr != nil {
continue
}
if err := zone.RemoveRichRule(ctx, richRule); err != nil { if err := zone.RemoveRichRule(ctx, richRule); err != nil {
return err return err
} }
} }
// Remove NAT rules. // Remove the modeled NAT entries, preserving forward ports the model cannot
// hold for the same reason.
for _, fp := range settings.ForwardPorts { for _, fp := range settings.ForwardPorts {
if f.modeledForwardPort(fp) == nil {
continue
}
if err := zone.RemoveForwardPort(ctx, fp); err != nil { if err := zone.RemoveForwardPort(ctx, fp); err != nil {
return err return err
} }

View file

@ -35,17 +35,24 @@ func TestFirewallDZonePortRulesSkipsUnmodeledProto(t *testing.T) {
func TestFirewallDRichRules(t *testing.T) { func TestFirewallDRichRules(t *testing.T) {
fw := new(FirewallD) fw := new(FirewallD)
// Parse a rule that is expected to parse right. // Parse a rule that is expected to parse right: an action-scoped limit
rule, err := fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log limit value="1/m" audit accept`) // (after accept) is the traffic limit RateLimit models.
rule, err := fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log audit accept limit value="1/m"`)
require.NoError(t, err) require.NoError(t, err)
// Re-encode the rule which should result in expected rich rule. The log and // Re-encode the rule which should result in expected rich rule. The log and
// rate limit now round-trip (audit is still not modeled and is dropped). // rate limit round-trip (audit is still not modeled and is dropped).
richRule, err := fw.MarshalRichRule(rule) richRule, err := fw.MarshalRichRule(rule)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, `rule family="ipv4" source address="192.168.0.0/24" port port="23" protocol="udp" log level="info" accept limit value="1/m"`, richRule, require.Equal(t, `rule family="ipv4" source address="192.168.0.0/24" port port="23" protocol="udp" log level="info" accept limit value="1/m"`, richRule,
"the rich rule did not encode as expected") "the rich rule did not encode as expected")
// A log-scoped limit (directly after log) throttles only the logging, which
// the model cannot hold; reading it as a traffic limit would let a rewrite
// change what the rule enforces, so it must stay unmodeled.
_, err = fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log limit value="1/m" audit accept`)
require.Error(t, err, "a log-scoped limit must be rejected")
// Try encoding a bunch of invalid rules. // Try encoding a bunch of invalid rules.
invalidRules := []string{ invalidRules := []string{
`rule family=ipv4 source address="192.168.0.0/24" service name=ftp reject`, `rule family=ipv4 source address="192.168.0.0/24" service name=ftp reject`,
@ -194,8 +201,7 @@ func TestFirewallDICMPType(t *testing.T) {
// matching a destination ipset must marshal to `destination ipset="..."` and // matching a destination ipset must marshal to `destination ipset="..."` and
// round-trip, rather than being rejected or read back as invisible. Unlike a // round-trip, rather than being rejected or read back as invisible. Unlike a
// source ipset, firewalld rejects a familyless destination ipset with // source ipset, firewalld rejects a familyless destination ipset with
// MISSING_FAMILY (verified against the real firewalld.core.rich parser), so the // MISSING_FAMILY, so the caller must supply a concrete Family.
// caller must supply a concrete Family.
func TestFirewallDDestinationIPSet(t *testing.T) { func TestFirewallDDestinationIPSet(t *testing.T) {
fw := new(FirewallD) fw := new(FirewallD)
@ -207,7 +213,8 @@ func TestFirewallDDestinationIPSet(t *testing.T) {
parsed, err := fw.UnmarshalRichRule(got) parsed, err := fw.UnmarshalRichRule(got)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "myset", parsed.Destination) require.Equal(t, "myset", parsed.Destination)
require.True(t, parsed.IsOutput(), "a destination match is an output rule") require.False(t, parsed.IsOutput(),
"a destination element is an inbound-frame match, not a direction signal")
// A negated destination ipset round-trips too. // A negated destination ipset round-trips too.
neg := &Rule{Family: IPv4, Destination: "!badset", Proto: TCP, Port: 443, Action: Drop} neg := &Rule{Family: IPv4, Destination: "!badset", Proto: TCP, Port: 443, Action: Drop}
@ -460,12 +467,10 @@ func TestFirewalldZoneEntryEligibleRange(t *testing.T) {
} }
// TestFirewallDBareICMPFamilyPinned guards that a bare (untyped) ICMP/ICMPv6 rule // TestFirewallDBareICMPFamilyPinned guards that a bare (untyped) ICMP/ICMPv6 rule
// is left unqualified by family and still round-trips correctly. Live testing // is left unqualified by family and still round-trips correctly. firewalld only
// against firewalld 2.4.3 confirmed it accepts a familyless rich rule carrying // requires a family for a source/destination address, never for a bare protocol
// `protocol value="ipv6-icmp"` just like any other protocol (its rich.py check() // match, and the protocol value string alone ("icmp" vs "ipv6-icmp") tells the
// only requires a family for a source/destination address, never for a bare // read path ICMP from ICMPv6.
// protocol match), and the protocol value string alone ("icmp" vs "ipv6-icmp")
// tells the read path ICMP from ICMPv6 without needing a family qualifier.
func TestFirewallDBareICMPFamilyPinned(t *testing.T) { func TestFirewallDBareICMPFamilyPinned(t *testing.T) {
fw := new(FirewallD) fw := new(FirewallD)
@ -595,3 +600,26 @@ func TestFirewalldICMPv6DestinationUnreachable(t *testing.T) {
require.NoError(t, err, "an icmpv6 destination-unreachable rich rule must parse") require.NoError(t, err, "an icmpv6 destination-unreachable rich rule must parse")
require.True(t, r.Equal(back, false), "icmpv6 destination-unreachable must round-trip: got %+v", back) require.True(t, r.Equal(back, false), "icmpv6 destination-unreachable must round-trip: got %+v", back)
} }
// modeledForwardPort must reject the forward-port shapes the write side
// (forwardPort) cannot re-create — otherwise GetNATRules would surface entries
// that can never be removed or restored, and Restore would destroy them.
func TestFirewallDModeledForwardPort(t *testing.T) {
fw := new(FirewallD)
dnat := fw.modeledForwardPort(firewalld.ForwardPort{Port: "8080", Protocol: "tcp", ToPort: "80", ToAddr: "10.0.0.5"})
require.NotNil(t, dnat)
require.Equal(t, DNAT, dnat.Kind)
require.EqualValues(t, 8080, dnat.Port)
require.EqualValues(t, 80, dnat.ToPort)
redirect := fw.modeledForwardPort(firewalld.ForwardPort{Port: "8443", Protocol: "udp", ToPort: "443"})
require.NotNil(t, redirect)
require.Equal(t, Redirect, redirect.Kind)
// Shapes forwardPort rejects stay unmodeled: a non-tcp/udp protocol and a
// target-port range.
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "9999", Protocol: "sctp", ToPort: "80", ToAddr: "10.0.0.5"}))
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "9999", Protocol: "dccp"}))
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "8080", Protocol: "tcp", ToPort: "8080-8090", ToAddr: "10.0.0.5"}))
}

4
go.mod
View file

@ -2,11 +2,9 @@ module git.gec.im/GRMrGecko/go-firewall
go 1.26.4 go 1.26.4
replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0
require ( require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/coreos/go-systemd/v22 v22.5.0
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847 github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1

View file

@ -45,13 +45,18 @@ type hookScript struct {
// through the hook: a forward-chain (routed) rule, connection-state matching, // through the hook: a forward-chain (routed) rule, connection-state matching,
// per-rule interface matching, logging, rate limiting, ICMPv6, a transport // per-rule interface matching, logging, rate limiting, ICMPv6, a transport
// protocol their native config does not model (SCTP and the portless IP protocols // protocol their native config does not model (SCTP and the portless IP protocols
// GRE, ESP and AH), or an address-set reference (@set) — csf.allow/apf trust files // GRE, ESP and AH), an address-set reference (@set), or a negated address.
// take literal addresses only, so a `-m set --match-set` match lives in the hook // csf.allow/apf trust files take literal addresses only, so a `-m set
// beside the ipset commands that create the set. // --match-set` match lives in the hook beside the ipset commands that create the
// set, and a negated address lives there as iptables' native `! -s`/`! -d` —
// csf.pl passes an advanced line's s=/d= value verbatim to iptables (where a
// joined "!" is not a reliable negation) and skips a plain "!"-prefixed line as
// not an address, so neither native form can carry one.
func ruleNeedsHook(r *Rule) bool { func ruleNeedsHook(r *Rule) bool {
return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" || return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" ||
r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto) || r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto) ||
isSetRef(r.Source) || isSetRef(r.Destination) isSetRef(r.Source) || isSetRef(r.Destination) ||
strings.HasPrefix(r.Source, "!") || strings.HasPrefix(r.Destination, "!")
} }
// ipv6Unavailable reports whether r resolves to IPv6 while the backend's own IPv6 // ipv6Unavailable reports whether r resolves to IPv6 while the backend's own IPv6
@ -164,19 +169,28 @@ func dirAnyPlainLine(r *Rule) bool {
return r.Direction == DirAny && bareHostShape(r) && !ruleNeedsHook(r) return r.Direction == DirAny && bareHostShape(r) && !ruleNeedsHook(r)
} }
// hostNeedsHook reports whether an address-bearing rule with no port must be // shapeNeedsHook reports whether a rule's shape overflows every native CSF/APF
// injected through the raw-iptables hook because a csf/apf trust file has no form // config form and must be injected through the raw-iptables hook. The native forms
// for it: a source+destination pair (both store a single address) or a host // are narrow — a plain trust-file line holds one address, matches both directions,
// pinned to a concrete tcp/udp protocol (the plain line is all-protocol and the // and is all-protocol; an advanced line holds exactly one address field and one
// advanced rule requires a port). Both are expressed natively as an iptables // port-flow field and requires both; the conf lists key on a port or an icmp type —
// rule. A single-address all-protocol host is not covered here — a one-way one is // while iptables expresses every overflow directly (`-s` with `-d`, `--sport` with
// routed by bareHostOneWay, a bidirectional one is a native plain line — and ICMP // `--dport`, a bare `-p tcp -j ACCEPT`), so these shapes are hooked rather than
// keeps its own handling (CSF.nativeICMP, APF.isConfRule), so it is excluded. Shared // rejected: the alternative is failing on a rule the firewall can enforce.
// by CSF and APF. //
func hostNeedsHook(r *Rule) bool { // Feature-based routing (state, interface, logging, rate limiting, ICMPv6,
if r.Proto.IsICMP() || r.HasPorts() || r.HasSourcePorts() { // hook-only transports, set references, negation) is ruleNeedsHook's job, and the
return false // backend-specific connlimit, ICMP and port-list shapes stay in each backend's
// needsHook; ICMP is excluded from every test here because both backends route it
// there (CSF's typed advanced rule, APF.isConfRule). Shared by CSF and APF.
func shapeNeedsHook(r *Rule) bool {
// A one-way bare host: a plain line is bidirectional and an advanced rule
// requires a port, so a concrete-direction bare host has no native form.
if bareHostOneWay(r) {
return true
} }
// Portless address shapes no trust file expresses.
if !r.Proto.IsICMP() && !r.HasPorts() && !r.HasSourcePorts() {
// A source+destination pair has no single-address advanced/plain form. // A source+destination pair has no single-address advanced/plain form.
if r.Source != "" && r.Destination != "" { if r.Source != "" && r.Destination != "" {
return true return true
@ -184,32 +198,15 @@ func hostNeedsHook(r *Rule) bool {
// A single-address host pinned to a transport has no portless form: the plain // 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 — // 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. // it names transports, so it is not the all-protocol plain line either.
if r.Source != "" || r.Destination != "" { if (r.Source != "" || r.Destination != "") && onProtocolAxis(r.Proto) {
return onProtocolAxis(r.Proto) return true
} }
return false
}
// advRuleNeedsHook reports whether a rule that would otherwise be written as a
// csf/apf advanced rule must instead be injected through the raw-iptables hook,
// because the advanced-line format cannot carry its shape. An advanced line holds
// exactly one address field and exactly one port-flow field, and requires an
// address, so three shapes overflow it: a source+destination pair (the second
// address has nowhere to go), a source port matched together with a destination
// port, and an address-less source-port match. iptables expresses all three directly
// (`-s`/`-d`, `--sport`/`--dport`), so they are hooked rather than rejected — the
// alternative is failing on a rule the firewall can enforce.
//
// hostNeedsHook covers the portless address shapes and bows out once a rule carries
// a port, so this predicate is what routes their ported counterparts. Only the
// protocols an iptables port or icmp match accepts are routed here: a port on
// ProtocolAny is inexpressible in iptables too, so it stays on the native path and is
// rejected there by iptablesRuleValid rather than reaching the hook and failing there.
// Shared by CSF and APF.
func advRuleNeedsHook(r *Rule) bool {
if !onProtocolAxis(r.Proto) && !r.Proto.IsICMP() {
return false
} }
// Advanced-line overflows, on the protocols an iptables port or icmp match
// accepts: a port on ProtocolAny is inexpressible in iptables too, so it stays
// on the native path and is rejected there by iptablesRuleValid rather than
// reaching the hook and failing there.
if onProtocolAxis(r.Proto) || r.Proto.IsICMP() {
// One port-flow field: a source port and a destination port cannot share it. // One port-flow field: a source port and a destination port cannot share it.
if r.HasPorts() && r.HasSourcePorts() { if r.HasPorts() && r.HasSourcePorts() {
return true return true
@ -219,24 +216,16 @@ func advRuleNeedsHook(r *Rule) bool {
if r.HasSourcePorts() && r.Source == "" && r.Destination == "" { if r.HasSourcePorts() && r.Source == "" && r.Destination == "" {
return true return true
} }
// One address field: a source+destination pair cannot share it. Only the advanced // One address field: a ported or icmp-matching source+destination pair cannot
// form is at stake, so a rule with neither a port nor an icmp match — which never // share it (its portless non-ICMP counterpart is routed above).
// reaches MarshalAdvRule — is left to hostNeedsHook. if r.Source != "" && r.Destination != "" &&
if r.Source != "" && r.Destination != "" { (r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()) {
return r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP() return true
} }
return false
} }
// A bare protocol match — a non-ICMP transport with no address and no port — has
// bareProtoNeedsHook reports whether a rule is a bare protocol match — a non-ICMP // no native construct (the trust files key on an address, the conf lists on a
// transport with no address and no port — that CSF/APF cannot express in their // port or icmp type) but iptables applies it directly.
// native config (the trust files key on an address and the conf lists key on a
// port or icmp type) but iptables applies directly (`-p tcp -j ACCEPT`, or a bare
// `-j ACCEPT`). Such a rule is injected through the raw-iptables hook rather than
// rejected. ICMP/ICMPv6 keep their own native/hook handling (CSF.nativeICMP,
// APF.isConfRule, APF.nativeICMPv6, ruleNeedsHook), so they are excluded here. Shared
// by CSF and APF.
func bareProtoNeedsHook(r *Rule) bool {
return r.Source == "" && r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && !r.Proto.IsICMP() return r.Source == "" && r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && !r.Proto.IsICMP()
} }
@ -307,13 +296,13 @@ func hookCommand(fam Family) string {
} }
// shellSafeToken quotes a token so /bin/sh passes it through verbatim. The // shellSafeToken quotes a token so /bin/sh passes it through verbatim. The
// iptables marshaller quotes free-text fields (a comment, a log prefix) with // iptables marshaller quotes free-text fields (a comment, a log prefix) for an
// strconv.Quote — Go double-quoting, which is right for an iptables-restore file // iptables-restore file, where double quotes are literal — but the hook is
// but NOT for the hook script, which /bin/sh sources: inside double quotes the // sourced by /bin/sh, which expands $var, $(...) and backticks inside double
// shell still expands $var, $(...) and backticks. A token made of ordinary // quotes. A token made of ordinary argument characters is returned bare for
// argument characters is returned bare for readability; anything else is wrapped // readability; anything else is wrapped in single quotes (with any embedded
// in single quotes (with any embedded single quote escaped), which the shell // single quote escaped), which the shell treats as a literal. shlex.Split
// treats as a literal. shlex.Split reverses either form on read-back. // reverses either form on read-back.
func shellSafeToken(tok string) string { func shellSafeToken(tok string) string {
safe := tok != "" safe := tok != ""
for _, r := range tok { for _, r := range tok {
@ -331,11 +320,65 @@ func shellSafeToken(tok string) string {
} }
// rulesToLines encodes a rule as the raw command line(s) to inject, for the // rulesToLines encodes a rule as the raw command line(s) to inject, for the
// families the backend enforces (see writeFamilies). // families the backend enforces (see writeFamilies). A family-agnostic rule
// that references an address set is pinned to the set's family instead: an
// ipset is single-family, so the opposite-family line would fail every time
// the firewall sources the hook.
func (h *hookScript) rulesToLines(r *Rule) ([]string, error) { func (h *hookScript) rulesToLines(r *Rule) ([]string, error) {
if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) {
fam, err := h.setRefFamily(r)
if err != nil {
return nil, err
}
if fam == IPv6 && !h.ipv6Enabled {
return nil, fmt.Errorf("rule references an IPv6 address set while IPv6 is disabled: %w", ErrUnsupported)
}
return h.linesForFamilies(r, []Family{fam})
}
return h.linesForFamilies(r, h.writeFamilies(r)) return h.linesForFamilies(r, h.writeFamilies(r))
} }
// setRefFamily resolves the single family of the address set(s) a rule
// references from the hook's ipset lines. A set that is not in the hook, or a
// source/destination pair naming sets of different families, cannot produce a
// loadable line, so both are errors.
func (h *hookScript) setRefFamily(r *Rule) (Family, error) {
sets, err := h.getAddressSets()
if err != nil {
return FamilyAny, err
}
fam := FamilyAny
for _, ref := range []string{r.Source, r.Destination} {
if !isSetRef(ref) {
continue
}
_, bare := splitAddrNeg(strings.TrimSpace(ref))
name := strings.TrimPrefix(bare, "@")
var found *AddressSet
for _, s := range sets {
if s.Name == name {
found = s
break
}
}
if found == nil {
return FamilyAny, fmt.Errorf("rule references unknown address set %q", name)
}
sf := found.Family
if sf != IPv6 {
sf = IPv4
}
if fam != FamilyAny && sf != fam {
return FamilyAny, fmt.Errorf("rule references address sets of different families")
}
fam = sf
}
if fam == FamilyAny {
fam = IPv4
}
return fam, nil
}
// removalLines encodes every hook line a rule could occupy, across both families // removalLines encodes every hook line a rule could occupy, across both families
// regardless of the backend's IPv6 setting. Removal sweeps the wider set so an // regardless of the backend's IPv6 setting. Removal sweeps the wider set so an
// ip6tables line written while IPv6 was enabled — or added by hand — is still // ip6tables line written while IPv6 was enabled — or added by hand — is still
@ -390,6 +433,26 @@ func ruleMatchesAny(e *Rule, targets []*Rule) bool {
return false return false
} }
// orphanLogMatchesAny reports whether e is a LOG line whose action partner is
// gone from the hook and whose logged rule is named by one of targets, so a
// removal still clears the stray line.
func orphanLogMatchesAny(e *Rule, targets []*Rule) bool {
if e.Action != ActionInvalid || !e.Log {
return false
}
for _, t := range targets {
if !t.Log {
continue
}
tl := *t
tl.Action = ActionInvalid
if e.Equal(&tl, true) {
return true
}
}
return false
}
// parseLine decodes an injected command line back into the rule it represents // parseLine decodes an injected command line back into the rule it represents
// (one line, so a LOG line yields a rule with Log set and no action), reporting // (one line, so a LOG line yields a rule with Log set and no action), reporting
// whether the line is one this backend recognizes. // whether the line is one this backend recognizes.
@ -429,9 +492,8 @@ func (h *hookScript) commandLines() ([]string, error) {
} }
var cmds []string var cmds []string
for _, line := range lines { for _, line := range lines {
t := strings.TrimSpace(line) if isHookRuleLine(line) {
if strings.HasPrefix(t, "iptables ") || strings.HasPrefix(t, "ip6tables ") { cmds = append(cmds, strings.TrimSpace(line))
cmds = append(cmds, t)
} }
} }
return cmds, nil return cmds, nil
@ -472,8 +534,9 @@ func (h *hookScript) readHookLines() ([]string, bool, error) {
return strings.Split(content, "\n"), true, nil return strings.Split(content, "\n"), true, nil
} }
// writeHook atomically replaces the hook with lines, giving a freshly created // writeHook atomically replaces the hook with lines. A freshly created hook
// hook a shebang, and keeps it executable so the firewall can source it. // gets a shebang and the executable hookPerm; an existing hook keeps its own
// mode and ownership (see writeConfigFile).
func (h *hookScript) writeHook(lines []string, existed bool) error { func (h *hookScript) writeHook(lines []string, existed bool) error {
var b strings.Builder var b strings.Builder
if !existed { if !existed {
@ -483,8 +546,6 @@ func (h *hookScript) writeHook(lines []string, existed bool) error {
b.WriteString(l) b.WriteString(l)
b.WriteByte('\n') b.WriteByte('\n')
} }
// Preserve an existing hook's mode and ownership; a freshly created hook gets
// the executable hookPerm so the firewall can source it.
if err := writeConfigFile(h.hookPath, []byte(b.String()), h.hookPerm); err != nil { if err := writeConfigFile(h.hookPath, []byte(b.String()), h.hookPerm); err != nil {
return fmt.Errorf("failed to move updated hook into place: %s", err) return fmt.Errorf("failed to move updated hook into place: %s", err)
} }
@ -494,13 +555,15 @@ func (h *hookScript) writeHook(lines []string, existed bool) error {
// edit adds or removes a rule's command line(s) directly in the hook. Adding // edit adds or removes a rule's command line(s) directly in the hook. Adding
// matches on the exact deterministic line the marshaller emits, so the library's // matches on the exact deterministic line the marshaller emits, so the library's
// own tagged line is written once and re-adds are idempotent. Removal instead // own tagged line is written once and re-adds are idempotent. Removal instead
// matches on the underlying rule: a line is dropped when the rule it encodes is // matches on the underlying rule: a hook rule is dropped when it is the same rule
// the same as one r resolves to, ignoring the comment tag, so a copy of the rule a // as one r resolves to, ignoring the comment tag, so a copy of the rule a
// customer added under a different comment (or none) is cleared too — the comment // customer added under a different comment (or none) is cleared too — the comment
// is not part of rule identity. A logged rule's LOG and action lines are matched // is not part of rule identity. A LOG line and the action line under it are
// independently, exactly as they were written. It preserves every other hook // matched as the one logged rule they encode, never independently: a logged rule
// line — user-authored shell and rules alike — and reports whether the hook // and its unlogged twin are distinct rules, and removing one must not strip the
// changed. Adding to an absent hook creates it; removing from one is a no-op. // other's lines. It preserves every other hook line — user-authored shell and
// rules alike — and reports whether the hook changed. Adding to an absent hook
// creates it; removing from one is a no-op.
func (h *hookScript) edit(r *Rule, remove bool) (bool, error) { func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
// An add writes only the families the backend enforces; a removal sweeps both, so // An add writes only the families the backend enforces; a removal sweeps both, so
// a stale ip6tables line does not outlive an IPv6 switch-off (see writeFamilies). // a stale ip6tables line does not outlive an IPv6 switch-off (see writeFamilies).
@ -520,23 +583,50 @@ func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
changed := false changed := false
if remove { if remove {
// Parse each desired line back into the rule it encodes; a hook line whose own // Parse each desired line back into the rule it encodes — the round trip
// rule matches one of these (comment ignored, see Rule.Equal) is dropped. A line // normalizes field spellings — and coalesce each LOG line with its action
// that is not a rule the hook recognizes (foreign shell, a comment) never parses // line so the targets are logical rules. A desired line that fails to
// and is preserved. // parse breaks the marshal/parse round trip the removal match depends on,
var targets []*Rule // so it is an error rather than a silent no-op.
var parsed []*Rule
for _, l := range desired { for _, l := range desired {
if tr, ok := h.parseLine(l); ok { tr, ok := h.parseLine(l)
targets = append(targets, tr) if !ok {
return false, fmt.Errorf("hook removal target does not round-trip: %q", l)
} }
parsed = append(parsed, tr)
} }
targets := coalesceLoggedRules(parsed)
next := lines[:0:0] next := lines[:0:0]
for _, l := range lines { for i := 0; i < len(lines); i++ {
if er, ok := h.parseLine(l); ok && ruleMatchesAny(er, targets) { er, ok := h.parseLine(lines[i])
changed = true if !ok {
next = append(next, lines[i])
continue continue
} }
next = append(next, l) // Pair a LOG line with the action line directly under it, mirroring
// getRules' coalescing, so the pair is kept or dropped as one rule.
var partner *Rule
if i+1 < len(lines) {
partner, _ = h.parseLine(lines[i+1])
}
switch {
case logPartner(er, partner):
if ruleMatchesAny(mergeLogPair(er, partner), targets) {
changed = true
} else {
next = append(next, lines[i], lines[i+1])
}
i++
case orphanLogMatchesAny(er, targets):
// A stray LOG line whose action partner was hand-edited away
// still belongs to the logged rule named by the removal.
changed = true
case ruleMatchesAny(er, targets):
changed = true
default:
next = append(next, lines[i])
}
} }
if !changed { if !changed {
return false, nil return false, nil
@ -562,6 +652,191 @@ func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
return true, h.writeHook(lines, existed) return true, h.writeHook(lines, existed)
} }
// --- NAT rules (raw nat-table commands in the hook) --------------------------
//
// csf.redirect holds exactly two destination-NAT shapes, so every other NAT rule
// — source NAT, an interface-bound or source-matched translation, a port
// range/list — is injected as a raw `iptables -t nat` command through the same
// hook that carries the filter rules. csf flushes the v4 nat table on every
// (re)start whenever the kernel provides one (its Config.pm probes `-t nat -L
// POSTROUTING` and sets NAT=1) and sources the pre-hook afterwards, so the
// injected lines are applied exactly once per load; the v6 nat flush is guarded
// by IPV6, which is the same hazard natWriteFamilies narrows writes for. APF
// expresses the same escape hatch with its own preroute.rules/postroute.rules
// files instead of the hook.
// natRuleFamilies lists every address family a NAT rule's raw nat lines can
// occupy: a rule pinned to a family (by an address or its Family field) touches
// only that command; a family-agnostic rule (e.g. a portless masquerade) spans
// both v4 and v6. It is the full set, which is what removal must sweep;
// natWriteFamilies narrows it to the families the backend enforces. Shared by
// CSF's hook NAT lines and APF's routing files.
func natRuleFamilies(r *NATRule) []Family {
switch r.impliedFamily() {
case IPv4:
return []Family{IPv4}
case IPv6:
return []Family{IPv6}
default:
return []Family{IPv4, IPv6}
}
}
// natWriteFamilies lists the families a NAT rule's lines are written for. With
// the backend's own IPv6 handling off a family-agnostic rule is written for IPv4
// only: neither csf nor apf flushes the v6 nat table while IPv6 is disabled, so
// an injected ip6tables line would be re-appended on every reload and outlive
// its own removal. Removal still sweeps both families (natRuleFamilies) so a
// stale v6 line does not survive an IPv6 switch-off. It is the NAT analog of
// hookScript.writeFamilies. Shared by CSF and APF.
func natWriteFamilies(ipv6Enabled bool, r *NATRule) []Family {
fams := natRuleFamilies(r)
if ipv6Enabled || len(fams) == 1 {
return fams
}
return []Family{IPv4}
}
// natLine encodes a NAT rule as one raw nat-table command line for a family.
// Each marshalled line is re-tokenized and re-quoted shell-safely, as with
// linesForFamilies, because the hook is sourced by /bin/sh.
func (h *hookScript) natLine(r *NATRule, fam Family) (string, error) {
rc := *r
rc.Family = fam
ipt := &IPTables{rulePrefix: h.rulePrefix}
spec, err := ipt.MarshalNATRule(&rc)
if err != nil {
return "", err
}
tokens, err := shlex.Split(spec, true)
if err != nil {
return "", err
}
for i, t := range tokens {
tokens[i] = shellSafeToken(t)
}
return hookCommand(fam) + " -t nat " + strings.Join(tokens, " "), nil
}
// parseNATLine decodes a raw nat command line back into the NATRule it
// represents, reporting whether the line is one this backend recognizes. The
// iptables NAT parser derives HasPrefix from the line's comment tag, so a rule
// this library wrote reports it and a hand-added line does not. A `-t nat` line
// never doubles as a filter rule: parseLine's chain check (INPUT/OUTPUT/FORWARD)
// rejects it, so the two line kinds stay disjoint in the same hook.
func (h *hookScript) parseNATLine(line string) (*NATRule, bool) {
line = strings.TrimSpace(line)
var fam Family
var rest string
switch {
case strings.HasPrefix(line, "iptables -t nat "):
fam, rest = IPv4, strings.TrimPrefix(line, "iptables -t nat ")
case strings.HasPrefix(line, "ip6tables -t nat "):
fam, rest = IPv6, strings.TrimPrefix(line, "ip6tables -t nat ")
default:
return nil, false
}
ipt := &IPTables{rulePrefix: h.rulePrefix}
r, err := ipt.UnmarshalNATRule(rest, fam)
if err != nil {
return nil, false
}
return r, true
}
// getNATRules parses the raw nat-table rules the hook carries. Every such line
// is returned, including any a user authored by hand, so the library reconciles
// the hook's real state.
func (h *hookScript) getNATRules() ([]*NATRule, error) {
lines, _, err := h.readHookLines()
if err != nil {
return nil, err
}
var rules []*NATRule
for _, line := range lines {
if r, ok := h.parseNATLine(line); ok {
rules = append(rules, r)
}
}
return rules, nil
}
// editNAT adds or removes a NAT rule's raw nat command line(s) in the hook,
// mirroring edit for filter rules: an add writes only the families the backend
// enforces and dedups against an equivalent existing line — the exact text, or
// the same translation under EqualForRemoval, which stays family-aware so a
// family-scoped edit leaves an opposite-family twin alone — while a removal
// sweeps both families. Every other hook line is preserved; it reports whether
// the hook changed.
func (h *hookScript) editNAT(r *NATRule, remove bool) (bool, error) {
fams := natRuleFamilies(r)
if !remove {
fams = natWriteFamilies(h.ipv6Enabled, r)
}
want := make([]string, 0, len(fams))
wantSet := make(map[string]bool, len(fams))
for _, fam := range fams {
line, err := h.natLine(r, fam)
if err != nil {
return false, err
}
want = append(want, line)
wantSet[line] = true
}
lines, existed, err := h.readHookLines()
if err != nil {
return false, err
}
changed := false
next := make([]string, 0, len(lines)+len(want))
present := make(map[string]bool, len(want))
for _, raw := range lines {
body := strings.TrimSpace(raw)
matched := false
satisfied := ""
if wantSet[body] {
matched, satisfied = true, body
} else if existing, ok := h.parseNATLine(body); ok && existing.EqualForRemoval(r) {
matched = true
// The equivalent want-line is the one for this existing line's family;
// mark it satisfied so its duplicate is not appended below. Recompute it
// (rather than reuse body) since body is the existing spelling, not ours.
if line, lerr := h.natLine(r, existing.impliedFamily()); lerr == nil {
satisfied = line
}
}
if matched {
if remove {
changed = true
continue
}
if satisfied != "" {
present[satisfied] = true
}
}
next = append(next, raw)
}
if remove {
if !changed {
return false, nil
}
return true, h.writeHook(next, existed)
}
for _, line := range want {
if !present[line] {
next = append(next, line)
changed = true
}
}
if !changed {
return false, nil
}
return true, h.writeHook(next, existed)
}
// --- address sets (ipset commands in the hook) ----------------------------- // --- address sets (ipset commands in the hook) -----------------------------
// //
// CSF and APF have no native address-set construct, so the library persists a // CSF and APF have no native address-set construct, so the library persists a
@ -654,7 +929,10 @@ func (h *hookScript) getAddressSets() ([]*AddressSet, error) {
} }
for _, line := range lines { for _, line := range lines {
f := strings.Fields(line) f := strings.Fields(line)
if len(f) == 4 && f[0] == "ipset" && f[1] == "add" { // A hand-authored add may carry trailing options (`timeout 300`, `-exist`);
// the entry itself is still the fourth field. Options are not modeled, so a
// rewrite of the set's block re-emits the entry without them.
if len(f) >= 4 && f[0] == "ipset" && f[1] == "add" {
if s, ok := sets[f[2]]; ok { if s, ok := sets[f[2]]; ok {
s.Entries = append(s.Entries, f[3]) s.Entries = append(s.Entries, f[3])
} }
@ -674,6 +952,12 @@ func (h *hookScript) getAddressSets() ([]*AddressSet, error) {
// rule still references it. Every other hook line — user shell, rules, other // rule still references it. Every other hook line — user shell, rules, other
// sets — is preserved; it reports whether the hook changed. // sets — is preserved; it reports whether the hook changed.
func (h *hookScript) editAddressSet(set *AddressSet, remove bool) (bool, error) { func (h *hookScript) editAddressSet(set *AddressSet, remove bool) (bool, error) {
// hookIPSetName reports "" for every non-ipset line, so an unnamed set would
// match — and the keep-filter below would drop — every rule and user line in
// the hook.
if set.Name == "" {
return false, fmt.Errorf("an address set requires a name")
}
lines, existed, err := h.readHookLines() lines, existed, err := h.readHookLines()
if err != nil { if err != nil {
return false, err return false, err

View file

@ -25,6 +25,12 @@ func TestRuleNeedsHook(t *testing.T) {
// literal trust-file form, so it routes through the hook beside the ipset // literal trust-file form, so it routes through the hook beside the ipset
// commands that create the set. // commands that create the set.
{Family: IPv4, Source: "blocklist", Action: Drop}, {Family: IPv4, Source: "blocklist", Action: Drop},
// A negated address has no native form either: csf.pl passes an advanced
// line's s=/d= value verbatim to iptables and skips a plain "!"-prefixed
// line, so the hook's `! -s`/`! -d` is the only reliable spelling.
{Proto: TCP, Port: 22, Source: "!192.0.2.50/32", Action: Accept},
{Proto: TCP, Port: 22, Destination: "!192.0.2.51/32", Action: Accept},
{Source: "!192.0.2.52", Action: Accept},
} }
for _, r := range needs { for _, r := range needs {
require.True(t, ruleNeedsHook(r), "expected %+v to need the hook", *r) require.True(t, ruleNeedsHook(r), "expected %+v to need the hook", *r)
@ -40,22 +46,36 @@ func TestRuleNeedsHook(t *testing.T) {
} }
} }
// A csf/apf advanced line holds one address field and one port-flow field. A rule // shapeNeedsHook routes every rule shape the csf/apf native forms cannot hold —
// that overflows either must reach the raw-iptables hook, which matches -s/-d and // a one-way bare host, a portless source+destination pair or transport-pinned
// --sport/--dport together: before advRuleNeedsHook existed, AddRule fell through to // host, an advanced-line address/port-flow overflow, and a bare protocol match —
// MarshalAdvRule and failed on a rule the firewall can enforce. // to the raw-iptables hook, while the shapes a plain line, an advanced line, or a
func TestAdvRuleNeedsHook(t *testing.T) { // conf list expresses (and the ICMP shapes each backend routes itself) stay off it.
// Shapes the single address / single port-flow field cannot hold. func TestShapeNeedsHook(t *testing.T) {
// Shapes with no native csf/apf form.
needs := []*Rule{ needs := []*Rule{
// A source+destination pair carrying a port: hostNeedsHook bows out once a // A one-way bare host: a plain line is bidirectional, an advanced rule needs a
// rule has a port, so this predicate is what routes it. // port (an unset direction is DirInput, so the first is one-way too).
{Source: "1.2.3.4", Action: Accept},
{Direction: DirInput, Source: "1.2.3.4", Action: Drop},
{Direction: DirOutput, Destination: "1.2.3.4", Action: Accept},
// A portless source+destination pair: no single-address advanced/plain form.
{Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept, Direction: DirAny},
// A portless host pinned to a transport: the plain line is all-protocol and the
// advanced rule requires a port. TCPUDP names transports, so it counts.
{Proto: TCP, Source: "1.2.3.4", Action: Accept, Direction: DirAny},
{Proto: UDP, Destination: "1.2.3.4", Direction: DirOutput, Action: Accept},
{Proto: TCPUDP, Source: "192.0.2.1", Action: Accept, Direction: DirAny},
// A source+destination pair carrying a port or an ICMP match: the second
// address overflows the advanced line's single address field.
{Proto: TCP, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept}, {Proto: TCP, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
{Proto: TCPUDP, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept}, {Proto: TCPUDP, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
{Proto: TCP, SourcePort: 1234, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept}, {Proto: TCP, SourcePort: 1234, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
// A source+destination pair on an ICMP match, typed or not.
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept}, {Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
{Proto: ICMP, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept}, {Proto: ICMP, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
// A source port matched together with a destination port, addressed or not. {Proto: ICMPv6, Source: "2001:db8::1", Destination: "2001:db8::2", Action: Accept},
// A source port matched together with a destination port, addressed or not:
// they cannot share the advanced line's single port-flow field.
{Proto: TCP, Port: 80, SourcePort: 1234, Source: "192.0.2.1", Action: Accept}, {Proto: TCP, Port: 80, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
{Proto: UDP, Port: 53, SourcePort: 53, Destination: "192.0.2.1", Action: Accept}, {Proto: UDP, Port: 53, SourcePort: 53, Destination: "192.0.2.1", Action: Accept},
{Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept}, {Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept},
@ -63,25 +83,37 @@ func TestAdvRuleNeedsHook(t *testing.T) {
// advanced form of any kind. // advanced form of any kind.
{Proto: TCP, SourcePort: 1234, Action: Accept}, {Proto: TCP, SourcePort: 1234, Action: Accept},
{Proto: TCPUDP, SourcePort: 1234, Action: Drop}, {Proto: TCPUDP, SourcePort: 1234, Action: Drop},
// A bare protocol match: no address, no port, non-ICMP.
{Proto: TCP, Action: Accept},
{Proto: UDP, Action: Drop},
{Proto: ProtocolAny, Action: Accept},
{Proto: TCP, Direction: DirOutput, Action: Accept},
} }
for _, r := range needs { for _, r := range needs {
require.True(t, advRuleNeedsHook(r), "expected %+v to need the hook", *r) require.True(t, shapeNeedsHook(r), "expected %+v to need the hook", *r)
} }
// Shapes the advanced line holds, or that another predicate routes. // Shapes a native form holds, or that another route owns.
native := []*Rule{ native := []*Rule{
// A bidirectional bare host: the plain trust-file line itself.
{Direction: DirAny, Source: "1.2.3.4", Action: Accept},
// One address, one port-flow field: the advanced rule itself. // One address, one port-flow field: the advanced rule itself.
{Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept}, {Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept},
{Proto: TCP, SourcePort: 1234, Source: "192.0.2.1", Action: Accept}, {Proto: TCP, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.1", Action: Accept}, {Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.1", Action: Accept},
// A portless source+destination pair belongs to hostNeedsHook. // An address-less port rule: a native conf port list.
{Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept}, {Proto: TCP, Port: 22, Action: Accept},
// ICMP shapes with at most one address are routed by each backend's own
// needsHook (csf's typed advanced rule, apf's conf type lists), not here.
{Proto: ICMP, Source: "1.2.3.4", Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Proto: ICMPv6, Action: Accept},
// A port on ProtocolAny is inexpressible in iptables too, so it must keep its // A port on ProtocolAny is inexpressible in iptables too, so it must keep its
// own rejection rather than reach the hook and fail there. // own rejection rather than reach the hook and fail there.
{Proto: ProtocolAny, Port: 80, SourcePort: 1234, Source: "192.0.2.1", Action: Accept}, {Proto: ProtocolAny, Port: 80, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
{Proto: ProtocolAny, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept}, {Proto: ProtocolAny, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
} }
for _, r := range native { for _, r := range native {
require.False(t, advRuleNeedsHook(r), "expected %+v not to route to the hook", *r) require.False(t, shapeNeedsHook(r), "expected %+v not to route to the hook", *r)
} }
} }
@ -128,35 +160,6 @@ func TestIPv6UnavailableGate(t *testing.T) {
} }
} }
// bareProtoNeedsHook routes a portless, addressless non-ICMP match to the hook —
// the shape CSF/APF cannot express natively but iptables applies directly — while
// leaving every rule that carries an address, a port, or an ICMP protocol on its
// own native/ICMP path.
func TestBareProtoNeedsHook(t *testing.T) {
// Bare protocol matches with no address and no port go to the hook.
needs := []*Rule{
{Proto: TCP, Action: Accept},
{Proto: UDP, Action: Drop},
{Proto: ProtocolAny, Action: Accept},
{Proto: TCP, Direction: DirOutput, Action: Accept},
}
for _, r := range needs {
require.True(t, bareProtoNeedsHook(r), "expected %+v to route to the hook", *r)
}
// A port, an address, or an ICMP protocol keeps the rule off this path.
native := []*Rule{
{Proto: TCP, Port: 22, Action: Accept},
{Proto: TCP, SourcePort: 1234, Action: Accept},
{Proto: TCP, Source: "1.2.3.4", Action: Accept},
{Proto: ProtocolAny, Destination: "1.2.3.4", Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Proto: ICMPv6, Action: Accept},
}
for _, r := range native {
require.False(t, bareProtoNeedsHook(r), "expected %+v to stay off the hook route", *r)
}
}
// Two rules that are Equal (port-set order is not part of rule identity) must // Two rules that are Equal (port-set order is not part of rule identity) must
// inject the same command line, so a second add is a no-op and a remove using a // inject the same command line, so a second add is a no-op and a remove using a
// reordered port set still finds the rule. The hook script matches on the exact // reordered port set still finds the rule. The hook script matches on the exact
@ -513,33 +516,6 @@ func TestBareHostOneWay(t *testing.T) {
} }
} }
// hostNeedsHook selects the portless address rules a csf/apf trust file cannot
// express — a concrete tcp/udp host or a source+destination pair — so AddRule
// diverts them to the raw-iptables hook instead of rejecting them. An all-
// protocol single-address host (a native plain line or a bareHostOneWay hook
// rule), a port-bearing rule, an address-less rule, and ICMP stay off this path.
func TestHostNeedsHook(t *testing.T) {
cases := []struct {
name string
rule *Rule
want bool
}{
{"tcp host no port", &Rule{Proto: TCP, Source: "1.2.3.4"}, true},
{"udp host no port outbound", &Rule{Proto: UDP, Destination: "1.2.3.4", Direction: DirOutput}, true},
{"source and destination", &Rule{Source: "1.2.3.4", Destination: "5.6.7.8"}, true},
{"source and destination with proto", &Rule{Proto: TCP, Source: "1.2.3.4", Destination: "5.6.7.8"}, true},
{"all-protocol single host", &Rule{Source: "1.2.3.4"}, false},
{"tcp host with port", &Rule{Proto: TCP, Port: 22, Source: "1.2.3.4"}, false},
{"tcp host with source port", &Rule{Proto: TCP, SourcePort: 22, Source: "1.2.3.4"}, false},
{"address-less port rule", &Rule{Proto: TCP, Port: 22}, false},
{"icmp host", &Rule{Proto: ICMP, Source: "1.2.3.4"}, false},
{"icmpv6 source and destination", &Rule{Proto: ICMPv6, Source: "2001:db8::1", Destination: "2001:db8::2"}, false},
}
for _, c := range cases {
require.Equal(t, c.want, hostNeedsHook(c.rule), c.name)
}
}
// A set reference is not a literal host, so bareHostShape must reject it (else // A set reference is not a literal host, so bareHostShape must reject it (else
// APF/CSF would write the set name into a trust file) while ruleNeedsHook routes // APF/CSF would write the set name into a trust file) while ruleNeedsHook routes
// it to the hook. A literal address keeps the opposite verdicts. // it to the hook. A literal address keeps the opposite verdicts.
@ -737,3 +713,285 @@ func TestHookScriptRemoveSweepsV6AfterIPv6Disabled(t *testing.T) {
"a stale ip6tables line must be swept on removal, not stranded in the hook") "a stale ip6tables line must be swept on removal, not stranded in the hook")
require.NotContains(t, string(data), "iptables -A ") require.NotContains(t, string(data), "iptables -A ")
} }
// An unnamed set must be refused outright: hookIPSetName reports "" for every
// non-ipset line, so an empty name would match — and a rewrite would drop — every
// rule and user-authored line in the hook.
func TestHookAddressSetEmptyNamePreservesHook(t *testing.T) {
h := newTestHook(t)
_, err := h.editAddressSet(&AddressSet{Name: "keepme", Family: IPv4, Type: SetHashIP, Entries: []string{"192.0.2.1"}}, false)
require.NoError(t, err)
_, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 2299, Action: Accept, State: StateNew}, false)
require.NoError(t, err)
before, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
for _, remove := range []bool{true, false} {
changed, err := h.editAddressSet(&AddressSet{Name: ""}, remove)
require.Error(t, err, "an unnamed set must be refused (remove=%v)", remove)
require.False(t, changed)
}
after, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
require.Equal(t, string(before), string(after), "the hook must be untouched after a refused edit")
}
// A logged rule and its unlogged twin are distinct rules: removing one must not
// strip the other's lines. The LOG line and its action line are matched as the one
// logged rule they encode, so the unlogged target matches neither.
func TestHookRemoveLoggedAndUnloggedAreDistinct(t *testing.T) {
logged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"}
unlogged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, State: StateNew}
// Hook holds only the logged pair; removing the unlogged twin is a no-op.
h := newTestHook(t)
_, err := h.edit(logged, false)
require.NoError(t, err)
unloggedTwin := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept}
changed, err := h.edit(unloggedTwin, true)
require.NoError(t, err)
require.False(t, changed, "removing the unlogged twin must not touch the logged pair")
rules, err := h.getRules()
require.NoError(t, err)
require.Len(t, rules, 1)
require.True(t, rules[0].Log, "the logged rule must survive intact")
// Hook holds only the unlogged rule; removing the logged twin is a no-op.
h2 := newTestHook(t)
_, err = h2.edit(unlogged, false)
require.NoError(t, err)
changed, err = h2.edit(logged, true)
require.NoError(t, err)
require.False(t, changed, "removing the logged twin must not touch the unlogged rule")
// Removing the logged rule itself clears both of its lines.
changed, err = h.edit(logged, true)
require.NoError(t, err)
require.True(t, changed)
rules, err = h.getRules()
require.NoError(t, err)
require.Empty(t, rules)
data, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
require.NotContains(t, string(data), "-j LOG", "the LOG line must be removed with its action line")
}
// A stray LOG line whose action partner was hand-edited away still belongs to the
// logged rule, so removing that rule sweeps it rather than stranding a live kernel
// LOG rule the library no longer reports.
func TestHookRemoveSweepsOrphanLogLine(t *testing.T) {
h := newTestHook(t)
logged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"}
_, err := h.edit(logged, false)
require.NoError(t, err)
// Hand-remove the action line, leaving the LOG line orphaned.
data, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
var kept []string
for _, l := range strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") {
if strings.Contains(l, "-j ACCEPT") {
continue
}
kept = append(kept, l)
}
require.NoError(t, os.WriteFile(h.hookPath, []byte(strings.Join(kept, "\n")+"\n"), 0700))
changed, err := h.edit(logged, true)
require.NoError(t, err)
require.True(t, changed, "the orphan LOG line must be swept")
data, err = os.ReadFile(h.hookPath)
require.NoError(t, err)
require.NotContains(t, string(data), "-j LOG")
}
// A hand-authored add line may carry trailing options (`timeout 300`, `-exist`);
// the entry is still captured, so a rewrite of the set's block re-emits it instead
// of silently dropping the user's entry.
func TestHookAddressSetOptionedAddLineKeepsEntry(t *testing.T) {
h := newTestHook(t)
body := "#!/bin/sh\n" +
"ipset create blocklist hash:ip family inet -exist\n" +
"ipset flush blocklist\n" +
"ipset add blocklist 203.0.113.9 timeout 300\n"
require.NoError(t, os.WriteFile(h.hookPath, []byte(body), 0700))
sets, err := h.getAddressSets()
require.NoError(t, err)
require.Len(t, sets, 1)
require.Equal(t, []string{"203.0.113.9"}, sets[0].Entries)
// A rewrite of the block (adding a second entry) keeps the optioned entry.
changed, err := h.editAddressSetEntry("blocklist", "198.51.100.7", false)
require.NoError(t, err)
require.True(t, changed)
sets, err = h.getAddressSets()
require.NoError(t, err)
require.ElementsMatch(t, []string{"203.0.113.9", "198.51.100.7"}, sets[0].Entries)
}
// A family-agnostic set-referencing rule is pinned to the set's family: an ipset
// is single-family, so the opposite-family line would fail every time the firewall
// sources the hook. A reference to a set the hook does not carry is an error, and
// an IPv6 set is unusable while the backend's IPv6 handling is off.
func TestHookSetRefPinsFamilyAnyToSetFamily(t *testing.T) {
h := newTestHook(t)
h.ipv6Enabled = true
_, err := h.editAddressSet(&AddressSet{Name: "v6drop", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}}, false)
require.NoError(t, err)
lines, err := h.rulesToLines(&Rule{Source: "v6drop", Action: Drop})
require.NoError(t, err)
require.Len(t, lines, 1, "a family-agnostic set rule must be written for the set's family only")
require.True(t, strings.HasPrefix(lines[0], "ip6tables "), "want an ip6tables line, got %q", lines[0])
_, err = h.rulesToLines(&Rule{Source: "missing", Action: Drop})
require.Error(t, err, "a reference to an unknown set must be an error")
h.ipv6Enabled = false
_, err = h.rulesToLines(&Rule{Source: "v6drop", Action: Drop})
require.ErrorIs(t, err, ErrUnsupported, "an IPv6 set is unusable while IPv6 is off")
}
// The hook carries NAT rules csf.redirect cannot hold as raw `-t nat` command
// lines: an add round-trips through getNATRules, a re-add is idempotent, and a
// removal drops the line while leaving filter rules and user shell in place.
func TestHookScriptNATRoundTrip(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
ipv6Enabled: true,
}
cases := []*NATRule{
{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"},
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
{Kind: DNAT, Family: IPv4, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "10.0.0.5"},
{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 8080, Source: "192.0.2.0/24", ToPort: 80},
}
for _, r := range cases {
changed, err := h.editNAT(r, false)
require.NoError(t, err, "add %+v", *r)
require.True(t, changed)
changed, err = h.editNAT(r, false)
require.NoError(t, err)
require.False(t, changed, "re-adding %+v must be idempotent", *r)
got, err := h.getNATRules()
require.NoError(t, err)
require.Len(t, got, 1, "the added rule must read back exactly once")
require.True(t, got[0].Equal(r), "read-back mismatch: want %+v got %+v", *r, *got[0])
require.True(t, got[0].HasPrefix, "a hook NAT line this library wrote must report HasPrefix")
changed, err = h.editNAT(r, true)
require.NoError(t, err)
require.True(t, changed, "removal must drop %+v", *r)
got, err = h.getNATRules()
require.NoError(t, err)
require.Empty(t, got, "the rule must be gone after removal")
}
}
// A family-agnostic NAT rule fans out into an iptables and an ip6tables line
// with IPv6 on, narrows to IPv4 with it off, and a removal sweeps both families
// either way so a stale v6 line does not survive an IPv6 switch-off.
func TestHookScriptNATFamilyFanOut(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
ipv6Enabled: true,
}
masq := &NATRule{Kind: Masquerade, Interface: "eth1"}
_, err := h.editNAT(masq, false)
require.NoError(t, err)
data, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
require.Contains(t, string(data), "iptables -t nat -A POSTROUTING")
require.Contains(t, string(data), "ip6tables -t nat -A POSTROUTING")
// One family-agnostic removal clears both lines.
changed, err := h.editNAT(masq, true)
require.NoError(t, err)
require.True(t, changed)
got, err := h.getNATRules()
require.NoError(t, err)
require.Empty(t, got)
// With IPv6 off the write narrows to IPv4 only.
h.ipv6Enabled = false
_, err = h.editNAT(masq, false)
require.NoError(t, err)
data, err = os.ReadFile(h.hookPath)
require.NoError(t, err)
require.Contains(t, string(data), "iptables -t nat -A POSTROUTING")
require.NotContains(t, string(data), "ip6tables -t nat",
"a family-agnostic write must narrow to IPv4 while IPv6 is off")
// A stale v6 line (written while IPv6 was on, or by hand) is still swept.
v6line, err := h.natLine(masq, IPv6)
require.NoError(t, err)
require.NoError(t, os.WriteFile(h.hookPath, []byte("#!/bin/sh\n"+v6line+"\n"), 0700))
changed, err = h.editNAT(masq, true)
require.NoError(t, err)
require.True(t, changed, "removal must sweep the stale IPv6 line even with IPv6 off")
got, err = h.getNATRules()
require.NoError(t, err)
require.Empty(t, got)
}
// NAT lines share the hook with filter rules, ipset commands and user shell;
// each kind must be read by its own parser only and edits must leave the others
// byte-for-byte in place. A hand-added equivalent NAT line (different comment)
// satisfies an add and is cleared by a removal, mirroring filter-rule edits.
func TestHookScriptNATCoexistsWithFilterLines(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
ipv6Enabled: true,
}
filter := &Rule{Family: IPv4, Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}
_, err := h.edit(filter, false)
require.NoError(t, err)
snat := &NATRule{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"}
_, err = h.editNAT(snat, false)
require.NoError(t, err)
// Each parser sees only its own lines.
frules, err := h.getRules()
require.NoError(t, err)
require.Len(t, frules, 1, "the NAT line must not surface as a filter rule")
nrules, err := h.getNATRules()
require.NoError(t, err)
require.Len(t, nrules, 1, "the filter line must not surface as a NAT rule")
// Removing the NAT rule leaves the filter rule in place, and vice versa.
_, err = h.editNAT(snat, true)
require.NoError(t, err)
frules, err = h.getRules()
require.NoError(t, err)
require.Len(t, frules, 1, "a NAT removal must not touch filter lines")
// A hand-added equivalent line under a different comment dedups an add and is
// cleared by a removal: the comment is not part of rule identity.
foreign := &hookScript{rulePrefix: "acme", hookPath: h.hookPath, hookPerm: 0700, ipv6Enabled: true}
_, err = foreign.editNAT(snat, false)
require.NoError(t, err)
changed, err := h.editNAT(snat, false)
require.NoError(t, err)
require.False(t, changed, "an equivalent hand-added NAT line must satisfy the add")
changed, err = h.editNAT(snat, true)
require.NoError(t, err)
require.True(t, changed, "removal must clear the equivalent hand-added NAT line")
nrules, err = h.getNATRules()
require.NoError(t, err)
require.Empty(t, nrules)
}

View file

@ -4,6 +4,10 @@ package firewall
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"strings"
"testing" "testing"
) )
@ -39,3 +43,260 @@ func hookPlanter(mgr Manager) func(*Rule) error {
} }
return nil return nil
} }
// foreignSeeder returns a function seeding a foreign rule with the backend's own
// tooling, or nil when the backend has no seeder on this platform. The seeding
// commands/paths are inherently backend-specific; the assertions in the shared
// foreignrule subtest are not.
func foreignSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
switch mgr.Type() {
case IPTablesType:
// The iptables backend manages the persistent save files (rules.v4 /
// rules.v6), so the operator-style seed is a hand-edited save-file line,
// not a live `iptables -A` (which the file model deliberately never sees).
ipt, ok := mgr.(*IPTables)
if !ok {
return nil
}
return func(string) (*foreignSeed, error) {
undo, err := insertSaveFileRule(ipt.IP4Path, "-A INPUT -p tcp --dport 8123 -j ACCEPT")
if err != nil {
return nil, err
}
return &foreignSeed{
rule: &Rule{Family: IPv4, Proto: TCP, Port: 8123, Action: Accept},
inScope: true,
undo: undo,
}, nil
}
case NFTType:
return func(string) (*foreignSeed, error) {
const table = "foreignseed"
cmds := [][]string{
{"add", "table", "ip", table},
{"add", "chain", "ip", table, "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "policy", "accept", ";", "}"},
{"add", "rule", "ip", table, "input", "tcp", "dport", "8123", "accept"},
}
for _, c := range cmds {
if out, err := exec.Command("nft", c...).CombinedOutput(); err != nil {
_ = exec.Command("nft", "delete", "table", "ip", table).Run()
return nil, fmt.Errorf("nft %s: %v: %s", strings.Join(c, " "), err, out)
}
}
return &foreignSeed{
rule: &Rule{Family: IPv4, Proto: TCP, Port: 8123, Action: Accept},
inScope: false, // nft reports foreign tables but writes only to its own.
undo: func() { _ = exec.Command("nft", "delete", "table", "ip", table).Run() },
}, nil
}
case UFWType:
return func(string) (*foreignSeed, error) {
if out, err := exec.Command("ufw", "allow", "8123/tcp").CombinedOutput(); err != nil {
return nil, fmt.Errorf("ufw: %v: %s", err, out)
}
return &foreignSeed{
rule: &Rule{Proto: TCP, Port: 8123, Action: Accept},
inScope: true,
undo: func() { _ = exec.Command("ufw", "--force", "delete", "allow", "8123/tcp").Run() },
}, nil
}
case FirewallDType:
return func(zone string) (*foreignSeed, error) {
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-port=8123/tcp").CombinedOutput(); err != nil {
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
}
return &foreignSeed{
rule: &Rule{Proto: TCP, Port: 8123, Action: Accept},
// firewalld's container is the zone itself, so every rule read from
// it — foreign included — carries the informational flag.
hasPrefix: true,
inScope: true,
undo: func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-port=8123/tcp").Run()
},
}, nil
}
case CSFType:
return func(string) (*foreignSeed, error) {
undo, err := appendConfigLine(CSFAllow, "198.51.100.99")
if err != nil {
return nil, err
}
return &foreignSeed{
rule: &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.99", Action: Accept},
inScope: true,
undo: undo,
}, nil
}
case APFType:
return func(string) (*foreignSeed, error) {
undo, err := appendConfigLine(APFAllow, "198.51.100.99")
if err != nil {
return nil, err
}
return &foreignSeed{
rule: &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.99", Action: Accept},
inScope: true,
undo: undo,
}, nil
}
}
return nil
}
// foreignMACSeeder returns a function seeding a foreign MAC zone source with the
// backend's own tooling, or nil when the backend has no MAC-source construct on
// this platform. Only firewalld models one (a zone source) and only firewall-cmd
// can seed it; the assertions in the shared foreignmacsource subtest are not
// backend-specific.
func foreignMACSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
if mgr.Type() != FirewallDType {
return nil
}
return func(zone string) (*foreignSeed, error) {
const mac = "00:11:22:33:44:55"
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-source="+mac).CombinedOutput(); err != nil {
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
}
return &foreignSeed{
rule: &Rule{Source: mac, Action: Accept},
hasPrefix: true,
inScope: true,
undo: func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-source="+mac).Run()
},
}, nil
}
}
// foreignProtocolSeeder returns a function seeding a foreign bare-protocol allow
// with the backend's own tooling, or nil when the backend has no distinct
// protocol-entry construct on this platform. Only firewalld stores one (a zone
// protocol entry, distinct from the rich-rule form the library writes).
func foreignProtocolSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
if mgr.Type() != FirewallDType {
return nil
}
return func(zone string) (*foreignSeed, error) {
const proto = "gre"
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-protocol="+proto).CombinedOutput(); err != nil {
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
}
return &foreignSeed{
rule: &Rule{Proto: GRE, Action: Accept},
hasPrefix: true,
inScope: true,
undo: func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-protocol="+proto).Run()
},
}, nil
}
}
// unmanagedRawRuleSeeder returns a function injecting a parseable rule into a
// raw rules file the backend deliberately does not manage, or nil when the
// backend keeps no such file on this platform. Only ufw has the before/after
// split: the library writes raw rules into before.rules only, so a rule seeded
// into after.rules must stay invisible to GetRules. The returned probe is what
// the seeded line would read back as if it were (wrongly) surfaced.
func unmanagedRawRuleSeeder(mgr Manager) func() (*Rule, func(), error) {
if mgr.Type() != UFWType {
return nil
}
return func() (*Rule, func(), error) {
const afterPath = "/etc/ufw/after.rules"
orig, err := os.ReadFile(afterPath)
if err != nil {
return nil, nil, err
}
// Inject before the real COMMIT directive. after.rules carries a "# don't
// delete the 'COMMIT' line" comment, so match the standalone COMMIT line
// rather than the first literal.
lines := strings.Split(string(orig), "\n")
placed := false
for i, l := range lines {
if strings.TrimSpace(l) == "COMMIT" {
lines = append(lines[:i:i], append([]string{"-A ufw-after-input -p tcp -m tcp --dport 8765 -j ACCEPT"}, lines[i:]...)...)
placed = true
break
}
}
if !placed {
return nil, nil, fmt.Errorf("%s has no COMMIT directive to inject before", afterPath)
}
if err := os.WriteFile(afterPath, []byte(strings.Join(lines, "\n")), 0o640); err != nil {
return nil, nil, err
}
probe := &Rule{Family: IPv4, Proto: TCP, Port: 8765, Action: Accept}
return probe, func() { _ = os.WriteFile(afterPath, orig, 0o640) }, nil
}
}
// zoneInterfaceSeeder returns a function binding an interface to a named zone
// out of band (permanent config only, so nothing filters at runtime), or nil
// when the backend has no interface-to-zone mapping on this platform. Only
// firewalld models one and only firewall-cmd can seed it; the assertions in the
// shared zones subtest are not backend-specific.
func zoneInterfaceSeeder(mgr Manager) func(iface, zoneName string) (func(), error) {
if mgr.Type() != FirewallDType {
return nil
}
return func(iface, zoneName string) (func(), error) {
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zoneName, "--add-interface="+iface).CombinedOutput(); err != nil {
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
}
return func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zoneName, "--remove-interface="+iface).Run()
}, nil
}
}
// insertSaveFileRule inserts one iptables-save rule line into path's *filter
// section, before its COMMIT, returning an undo that restores the original
// content byte for byte.
func insertSaveFileRule(path, line string) (func(), error) {
orig, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(string(orig), "\n")
inFilter, placed := false, false
for i, l := range lines {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(trimmed, "*") {
inFilter = trimmed == "*filter"
continue
}
if inFilter && trimmed == "COMMIT" {
lines = append(lines[:i:i], append([]string{line}, lines[i:]...)...)
placed = true
break
}
}
if !placed {
return nil, fmt.Errorf("%s has no *filter COMMIT to insert before", path)
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o600); err != nil {
return nil, err
}
return func() { _ = os.WriteFile(path, orig, 0o600) }, nil
}
// appendConfigLine appends one line to a config file, returning an undo that
// restores the original content byte for byte.
func appendConfigLine(path, line string) (func(), error) {
orig, err := os.ReadFile(path)
if err != nil {
return nil, err
}
content := string(orig)
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
// The file exists, so WriteFile keeps its mode; the permission argument only
// applies on create.
if err := os.WriteFile(path, []byte(content+line+"\n"), 0o600); err != nil {
return nil, err
}
return func() { _ = os.WriteFile(path, orig, 0o600) }, nil
}

View file

@ -9,3 +9,35 @@ package firewall
func hookPlanter(mgr Manager) func(*Rule) error { func hookPlanter(mgr Manager) func(*Rule) error {
return nil return nil
} }
// foreignSeeder reports no out-of-band foreign-rule seeder on this platform; the
// Linux implementation in integration_linux_test.go covers the Linux backends.
// The shared suite skips its foreignrule probe on a nil result. Seeding pf (pfctl
// anchors) and wf (netsh advfirewall) the same way is still open.
func foreignSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
return nil
}
// foreignMACSeeder reports no MAC zone-source seeder on this platform: the MAC
// source is a firewalld construct and firewalld is Linux-only.
func foreignMACSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
return nil
}
// foreignProtocolSeeder reports no zone protocol-entry seeder on this platform,
// for the same reason as foreignMACSeeder.
func foreignProtocolSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
return nil
}
// unmanagedRawRuleSeeder reports no unmanaged raw rules file on this platform:
// the before/after split is a ufw construct and ufw is Linux-only.
func unmanagedRawRuleSeeder(mgr Manager) func() (*Rule, func(), error) {
return nil
}
// zoneInterfaceSeeder reports no interface-to-zone seeder on this platform: the
// interface binding is a firewalld construct and firewalld is Linux-only.
func zoneInterfaceSeeder(mgr Manager) func(iface, zoneName string) (func(), error) {
return nil
}

View file

@ -26,8 +26,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"os/exec"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -47,6 +47,26 @@ type backendFactory struct {
new func(ctx context.Context, rulePrefix string) (Manager, error) new func(ctx context.Context, rulePrefix string) (Manager, error)
} }
// foreignSeed is a rule planted out of band with a backend's own tooling, standing
// in for a rule a human operator added, plus what the library should observe for
// it. Each platform's foreignSeeder produces one; see integration_linux_test.go.
type foreignSeed struct {
// rule is what GetRules should report for the seeded rule.
rule *Rule
// hasPrefix is what the informational HasPrefix flag should read: false on the
// tag-based backends (no library comment/tag on the seeded rule) and for a
// foreign nft table, true on firewalld, whose container is the zone itself, so
// every rule read from it carries the flag.
hasPrefix bool
// inScope reports whether the backend's mutations reach the seeded rule.
// Everything but a foreign nft table is in scope; nft reports foreign tables
// but scopes its writes to its own, so RemoveRule must no-op there without
// error (Sync relies on exactly that).
inScope bool
// undo unseeds, best effort — the suite's RemoveRule normally already has.
undo func()
}
// runIntegration constructs each backend in backends and runs the capability suite // runIntegration constructs each backend in backends and runs the capability suite
// against it. It honors FIREWALL_BACKEND: when set, only that backend runs and a // against it. It honors FIREWALL_BACKEND: when set, only that backend runs and a
// construction failure is fatal (the environment is expected to provide it); when // construction failure is fatal (the environment is expected to provide it); when
@ -171,6 +191,46 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
require.True(t, matches[0].HasPrefix, "a rule this library added must report HasPrefix") require.True(t, matches[0].HasPrefix, "a rule this library added must report HasPrefix")
}) })
t.Run("foreignrule", func(t *testing.T) {
// The library manages the ACTUAL firewall, not only its own rules: a rule
// created out of band with the backend's own tooling must surface in
// GetRules and be removable with RemoveRule, or Sync/Backup/Restore could
// never converge on real firewall state. Each platform provides a seeder
// that plants a rule the way an operator would (foreignSeeder); a backend
// with no seeder skips.
seeder := foreignSeeder(mgr)
if seeder == nil {
t.Skip("no out-of-band seeder for this backend")
}
seed, err := seeder(zone)
if err != nil {
t.Skipf("could not seed a foreign rule: %v", err)
}
t.Cleanup(seed.undo)
rules := rulesOf(t, ctx, mgr, zone)
require.True(t, containsRule(rules, seed.rule, caps.Output),
"a rule seeded out of band must surface in GetRules, got %s", dumpRules(rules))
got := findRule(t, ctx, mgr, zone, seed.rule)
require.Equal(t, seed.hasPrefix, got.HasPrefix,
"HasPrefix must reflect how the backend namespaces its rules (informational only, never an ownership filter)")
if !seed.inScope {
// Reported but outside the backend's mutation scope (a foreign nft
// table): RemoveRule must no-op without error and leave the rule alone,
// or Sync would fail hard on every reconcile that sees it.
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule),
"removing a reported out-of-scope rule must no-op, not error")
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, caps.Output),
"an out-of-scope foreign rule must survive the no-op removal")
return
}
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule),
"a foreign rule is managed like any other and must be removable")
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, caps.Output),
"foreign rule still present after removal")
})
t.Run("output", func(t *testing.T) { t.Run("output", func(t *testing.T) {
requireCap(t, caps.Output) requireCap(t, caps.Output)
roundTripRule(t, ctx, mgr, zone, &Rule{Direction: DirOutput, Proto: TCP, Port: 8080, Action: Accept}) roundTripRule(t, ctx, mgr, zone, &Rule{Direction: DirOutput, Proto: TCP, Port: 8080, Action: Accept})
@ -196,69 +256,86 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "192.0.2.10/32", Action: Accept}) roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "192.0.2.10/32", Action: Accept})
}) })
t.Run("hosthooknoport", func(t *testing.T) { t.Run("hostnoport", func(t *testing.T) {
// csf/apf trust files store a single all-protocol address and their advanced // Portless host shapes. csf/apf trust files store a single all-protocol
// rule holds a single port, so a portless concrete-protocol host, a // address and their advanced rule holds a single port, so a portless
// source+destination pair, and (for apf) a multi-port list have no trust-file // concrete-protocol host, a source+destination pair, and (for apf) a
// form. apf routes all of these to the raw-iptables hook (hostNeedsHook / // multi-port list have no trust-file form there and route to the
// needsHook); csf routes the host shapes to the hook and expresses a // raw-iptables hook (shapeNeedsHook / needsHook), while csf expresses a
// multi-port list natively as a comma list. Either way each must round-trip. // multi-port list natively as a comma list. The chain backends express the
// Gated to csf/apf; the chain backends express these natively (covered // same shapes natively. Every backend runs every shape so a bug in
// elsewhere). // whichever path expresses it shows up; a backend that cannot express a
switch mgr.Type() { // shape at all skips via the ErrUnsupported sentinel.
case CSFType, APFType: t.Run("protohost", func(t *testing.T) {
default:
t.Skip("only csf/apf route these shapes to the hook")
}
// A concrete-protocol host with no port. // A concrete-protocol host with no port.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Source: "192.0.2.40/32", Action: Accept}) roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Proto: TCP, Source: "192.0.2.40/32", Action: Accept})
})
t.Run("srcdstpair", func(t *testing.T) {
// A source+destination pair with no port. // A source+destination pair with no port.
roundTripRule(t, ctx, mgr, zone, &Rule{Source: "192.0.2.41/32", Destination: "192.0.2.42/32", Action: Accept}) roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Source: "192.0.2.41/32", Destination: "192.0.2.42/32", Action: Accept})
})
t.Run("multiportdeny", func(t *testing.T) {
// A multi-port list as a deny: apf writes it to the hook with its literal // A multi-port list as a deny: apf writes it to the hook with its literal
// action, while csf writes it to csf.deny, whose action follows csf.conf // action, while csf writes it to csf.deny, whose action follows csf.conf
// (stock DROP). Probe both actions and round-trip the first the backend // (stock DROP). Probe both actions and round-trip the first the backend
// accepts, mirroring the denyaddress case. // accepts, mirroring the denyaddress case.
roundTripVariants(t, ctx, mgr, zone, roundTripVariantsOrSkip(t, ctx, mgr, zone,
&Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Reject}, &Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Reject},
&Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Drop}, &Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Drop},
) )
})
t.Run("multiporthost", func(t *testing.T) {
// A multi-port list as a host accept: apf hooks it, csf writes an advanced // A multi-port list as a host accept: apf hooks it, csf writes an advanced
// rule; no action ambiguity, so a direct round-trip covers both. // rule; no action ambiguity, so a direct round-trip covers both.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 6001, End: 6001}, {Start: 6002, End: 6002}}, Source: "192.0.2.43/32", Action: Accept}) roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 6001, End: 6001}, {Start: 6002, End: 6002}}, Source: "192.0.2.43/32", Action: Accept})
})
}) })
t.Run("advhookoverflow", func(t *testing.T) { t.Run("matchcombos", func(t *testing.T) {
// A csf/apf advanced line holds one address field and one port-flow field, so a // Combined matches that overflow a csf/apf advanced line — it holds exactly
// ported source+destination pair and a source-plus-destination port match both // one address field and one port-flow field — so both route them to their
// overflow it. Neither backend may reject them: iptables matches -s with -d and // raw-iptables hook (shapeNeedsHook); the chain backends match -s with -d
// --sport with --dport directly, so AddRule routes both to the raw-iptables hook // and --sport with --dport directly. Every backend runs every combo; the
// (advRuleNeedsHook). Gated to csf/apf; the chain backends express these // only capability in play is PortPair (firewalld's rich rules carry a
// natively and cover them elsewhere. // single port element). Port 22 is avoided in every field so an SSH-driven
switch mgr.Type() { // run keeps its session.
case CSFType, APFType: t.Run("portedpair", func(t *testing.T) {
default: // A source+destination pair carrying a destination port: the second address
t.Skip("only csf/apf route these shapes to the hook") // overflows the advanced line, so on csf/apf this is the shape that used to
} // reach MarshalAdvRule and fail. Every backend expresses it; no capability
// A source+destination pair carrying a destination port. hostNeedsHook stops at // gates it, so the round trip is unconditional.
// the port, so this is the shape that used to reach MarshalAdvRule and fail.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8443, Source: "192.0.2.50/32", Destination: "198.51.100.50/32", Action: Accept}) roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8443, Source: "192.0.2.50/32", Destination: "198.51.100.50/32", Action: Accept})
// The same pair on a source port, and on a typed ICMP match. })
t.Run("sourceportpair", func(t *testing.T) {
// The same pair on a source port.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: UDP, SourcePort: 5353, Source: "192.0.2.51/32", Destination: "198.51.100.51/32", Action: Accept}) roundTripRule(t, ctx, mgr, zone, &Rule{Proto: UDP, SourcePort: 5353, Source: "192.0.2.51/32", Destination: "198.51.100.51/32", Action: Accept})
})
t.Run("icmppair", func(t *testing.T) {
// The same pair on a typed ICMP match.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.52/32", Destination: "198.51.100.52/32", Action: Accept}) roundTripRule(t, ctx, mgr, zone, &Rule{Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.52/32", Destination: "198.51.100.52/32", Action: Accept})
// A source port matched together with a destination port, with and without an })
// address. Avoid port 22 in either field so an SSH-driven run keeps its session. t.Run("bothports", func(t *testing.T) {
// A source port matched together with a destination port, with an
// address. firewalld advertises PortPair false (a rich rule carries a
// single port element) and is gated out; everyone else must express it.
requireCap(t, caps.PortPair)
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8444, SourcePort: 5354, Source: "192.0.2.53/32", Action: Accept}) roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8444, SourcePort: 5354, Source: "192.0.2.53/32", Action: Accept})
})
t.Run("bothportsbare", func(t *testing.T) {
// The same source+destination port match without an address.
requireCap(t, caps.PortPair)
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8445, SourcePort: 5355, Action: Accept}) roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8445, SourcePort: 5355, Action: Accept})
}) })
})
t.Run("negatedaddress", func(t *testing.T) { t.Run("negatedaddress", func(t *testing.T) {
// ufw's tuple grammar cannot negate an address, so a negated source is routed requireCap(t, caps.Negation)
// to the before.rules raw path (iptables `! -s`) rather than rejected, and // A negated source must round-trip through whatever path expresses it:
// must round-trip through it. Gated to ufw: this exercises that specific // iptables/nft/pf/firewalld negate natively, ufw's tuple grammar cannot so
// reroute; other backends negate (or reject) through their own paths. // it reroutes to the before.rules raw path (iptables `! -s`), and csf/apf
if mgr.Type() != UFWType { // route it to their raw-iptables hook. wf advertises Negation false (WFP
t.Skip("exercises ufw's negated-address reroute to before.rules") // has no negated address condition) and is gated out; everyone else must
} // express it.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "!192.0.2.50/32", Action: Accept}) roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "!192.0.2.50/32", Action: Accept})
}) })
@ -273,44 +350,44 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
) )
}) })
t.Run("denyactionfromconfig", func(t *testing.T) { t.Run("denyexactaction", func(t *testing.T) {
// csf.deny / apf deny_hosts encode no action of their own: the tool applies // A deny must read back with the exact action it was added with, or a
// its deny chain with an action taken from config — csf.conf DROP (stock // caller managing it sees churn on every Sync (read back as one action,
// default DROP), apf conf.apf ALL_STOP (stock default DROP). A deny whose // never equal to the desired other). The stakes are highest on csf/apf,
// action matches that is stored natively and must read back with it, not a // whose deny stores encode no action of their own — the tool applies an
// fixed Reject — or a caller managing a Drop rule sees it churn on every Sync // action taken from config (csf.conf DROP / conf.apf ALL_STOP, stock
// (read back as Reject, never equal to the desired Drop). A deny whose action // default DROP), so a deny whose action matches config is stored natively
// differs has no native form but is expressible directly in iptables, so these // and one that differs is injected through their pre-hook rather than
// backends inject it through their pre-hook rather than refusing it; it must // refused — but the property holds for every backend, so both actions run
// round-trip with its exact action. Only the csf/apf address-list backends // everywhere. A backend with no reject action at all (wf) advertises
// derive a deny's action from config this way. // RejectAction false and skips that action by capability.
switch mgr.Type() { for _, tc := range []struct {
case "csf", "apf": name string
default: deny *Rule
t.Skip("backend does not derive a deny's action from config")
}
// The config-matching action (Drop on a stock host) is stored natively; the
// opposite action is injected through the hook. Both must read back exactly, so
// exercise both rather than assuming which one the host's config makes native.
for _, deny := range []*Rule{
{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.30/32", Action: Drop},
{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.31/32", Action: Reject},
}{ }{
require.NoError(t, mgr.AddRule(ctx, zone, deny), {"drop", &Rule{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.30/32", Action: Drop}},
"a deny must be storable natively or through the hook, never refused: %+v", deny) {"reject", &Rule{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.31/32", Action: Reject}},
} {
deny := tc.deny
t.Run(tc.name, func(t *testing.T) {
if deny.Action == Reject {
requireCap(t, caps.RejectAction)
}
require.NoError(t, mgr.AddRule(ctx, zone, deny))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, deny) }) t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, deny) })
// The rule must read back with its exact action so it compares equal to the // The rule must read back with its exact action so it compares equal to the
// desired rule and Sync leaves it in place rather than churning it. // desired rule and Sync leaves it in place rather than churning it.
got := findRule(t, ctx, mgr, zone, deny) got := findRule(t, ctx, mgr, zone, deny)
require.Equal(t, deny.Action, got.Action, require.Equal(t, deny.Action, got.Action,
"a deny must read back with its exact action, not the config's default: %+v", deny) "a deny must read back with its exact action, not a config default: %+v", deny)
require.True(t, got.Equal(deny, mgr.Capabilities().Output), require.True(t, got.Equal(deny, mgr.Capabilities().Output),
"the read-back deny must equal the desired rule so Sync does not churn it: %+v", deny) "the read-back deny must equal the desired rule so Sync does not churn it: %+v", deny)
require.NoError(t, mgr.RemoveRule(ctx, zone, deny)) require.NoError(t, mgr.RemoveRule(ctx, zone, deny))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), deny, mgr.Capabilities().Output), require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), deny, mgr.Capabilities().Output),
"deny rule still present after removal: %+v", deny) "deny rule still present after removal: %+v", deny)
})
} }
}) })
@ -358,7 +435,11 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
// A v4 rule and its v6 twin are two rows on most backends. Removing every rule // A v4 rule and its v6 twin are two rows on most backends. Removing every rule
// the backend reports must clear them all. Regression for a family-strict // the backend reports must clear them all. Regression for a family-strict
// remove that could not match a family-agnostic rule at all (a silent no-op // remove that could not match a family-agnostic rule at all (a silent no-op
// that left the port open) or that removed only one of the two rows. // that left the port open) or that removed only one of the two rows. Gated
// on FamilyWithoutAddress (the probe rules are family-pinned bare ports);
// the per-family sentinel-continue below covers only the environment case
// of a backend whose own IPv6 handling is off (csf/apf).
requireCap(t, caps.FamilyWithoutAddress)
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}
@ -434,12 +515,12 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
// 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;
// either way GetRules reports coverage for both families. Removing a single // either way GetRules reports coverage for both families. Removing a single
// family must leave the other in place — the backend splits the dual object. // family must leave the other in place — the backend splits the dual object
// Where its model cannot express a single-family rule of the shape (apf's // (apf splits its dual-stack port list through the raw-iptables hook). The
// dual-stack port lists, wf's address-less filters) it rejects the removal with // split is gated on FamilyWithoutAddress: wf's address-less filters cannot
// ErrUnsupported instead. Regression for a concrete-family removal that dropped // pin one family, so it advertises the capability false. Regression for a
// both families (nft/pf over-remove) or no-oped and left both (firewalld zone // concrete-family removal that dropped both families (nft/pf over-remove)
// entries under-remove). // or no-oped and left both (firewalld zone entries under-remove).
// splitCase is one dual-family rule shape plus a matcher over GetRules output. // splitCase is one dual-family rule shape plus a matcher over GetRules output.
type splitCase struct { type splitCase struct {
@ -448,8 +529,11 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
} }
// runSplit exercises a shape: add the FamilyAny rule, remove each family in // runSplit exercises a shape: add the FamilyAny rule, remove each family in
// turn, and confirm the other survives (or the backend rejects the removal). // turn, and confirm the other survives. A single-family removal implies
// expressing a single-family rule of the shape, so the split is gated on
// FamilyWithoutAddress (wf cannot pin a family without an address).
runSplit := func(t *testing.T, c splitCase) { runSplit := func(t *testing.T, c splitCase) {
requireCap(t, caps.FamilyWithoutAddress)
coverage := func() (v4Cov, v6Cov bool) { coverage := func() (v4Cov, v6Cov bool) {
for _, r := range rulesOf(t, ctx, mgr, zone) { for _, r := range rulesOf(t, ctx, mgr, zone) {
if !c.match(r) { if !c.match(r) {
@ -487,13 +571,9 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
t.Skipf("backend does not give this FamilyAny shape dual coverage (v4=%v v6=%v)", has4, has6) t.Skipf("backend does not give this FamilyAny shape dual coverage (v4=%v v6=%v)", has4, has6)
} }
// Remove IPv4; IPv6 must survive. A backend that cannot express a // Remove IPv4; IPv6 must survive. FamilyWithoutAddress is required
// single-family rule of this shape rejects with ErrUnsupported, so skip. // above, so a single-family removal of the shape must be expressible.
if err := mgr.RemoveRule(ctx, zone, c.v4); errors.Is(err, ErrUnsupported) { require.NoError(t, mgr.RemoveRule(ctx, zone, c.v4))
t.Skip("backend cannot express a single-family removal of this dual-family shape")
} else {
require.NoError(t, err)
}
has4, has6 := coverage() has4, has6 := coverage()
require.False(t, has4, "removing IPv4 must clear IPv4 coverage") require.False(t, has4, "removing IPv4 must clear IPv4 coverage")
require.True(t, has6, "removing IPv4 from a FamilyAny rule must leave IPv6 in place") require.True(t, has6, "removing IPv4 from a FamilyAny rule must leave IPv6 in place")
@ -1007,40 +1087,34 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
) )
}) })
t.Run("csfnativeicmp", func(t *testing.T) { t.Run("icmptypedhost", func(t *testing.T) {
// csf's only native ICMP form is an advanced rule carrying exactly one address // A typed ICMP match carrying a host address. The shared icmp/icmptype
// and a concrete type; every other shape routes to the hook (nativeICMP), so // probes stop at the first form a backend accepts, which hides this one:
// the shared icmp/icmptype probes match csf on its bare hook form first. // it is csf's only NATIVE ICMP form (a csf.allow advanced line carries
// Round-trip the native form here to keep the csf.allow advanced-rule path // exactly one address and a concrete type; every other shape routes to its
// covered. Gated to csf; type 13 (Timestamp) avoids echo-request collisions. // hook, see CSF.needsHook), and on apf — whose native ICMP is the address-less
if mgr.Type() != CSFType { // IG_ICMP_TYPES list — the shape that routes to the hook (needsHook). Every
t.Skip("csf-specific advanced-rule ICMP path") // backend runs it so both paths, and everyone else's addressed ICMP match,
} // stay covered. Type 13 (Timestamp) avoids colliding with Windows'
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.71/32", Action: Accept}) // unremovable built-in echo-request rules.
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.70/32", Action: Accept})
}) })
t.Run("apficmphook", func(t *testing.T) { t.Run("icmptypeddrop", func(t *testing.T) {
// apf carries ICMP as a zone-wide allowed-type list (IG_ICMP_TYPES), so an // A typed ICMP drop with no address. A non-accept action has no place in
// ICMP rule with an address or a non-accept action has no native form and // apf's allowed-type list, so it routes to apf's hook; the other backends
// routes to the hook (needsHook). The shared icmp/icmptype probes match // express it directly or skip via the ErrUnsupported sentinel.
// apf on its native address-less accept form first, so exercise the hook path roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Drop})
// here. Gated to apf; type 13 (Timestamp) avoids echo-request collisions.
if mgr.Type() != APFType {
t.Skip("apf-specific ICMP hook routing")
}
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.70/32", Action: Accept})
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Drop})
}) })
t.Run("apfsinglefamilyport", func(t *testing.T) { t.Run("singlefamilyport", func(t *testing.T) {
// apf's CPORTS lists are dual-stack, so a single-family bare port accept has requireCap(t, caps.FamilyWithoutAddress)
// no native form and is written per-family through the hook // A single-family bare port accept. Most backends store it directly; apf's
// (dualStackPortNeedsHook) rather than rejected. It must round-trip on its // CPORTS port lists are dual-stack, so it has no native form there and is
// own; the FamilyAny→single-family split is exercised by the destport split // written per-family through the hook (dualStackPortNeedsHook) rather than
// test. Gated to apf. // rejected. wf advertises FamilyWithoutAddress false (a WFP filter scopes
if mgr.Type() != APFType { // family through its address conditions) and is gated out. The
t.Skip("apf-specific single-family CPORTS-to-hook routing") // FamilyAny→single-family split is exercised by the destport split test.
}
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: TCP, Port: 8090, Action: Accept}) roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: TCP, Port: 8090, Action: Accept})
}) })
@ -1106,29 +1180,29 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
) )
}) })
t.Run("apfnativeconnlimit", func(t *testing.T) { t.Run("connlimitpersource", func(t *testing.T) {
// apf writes a native connection limit — a single inbound tcp/udp port, no requireCap(t, caps.ConnLimit)
// address, per-source, rejecting the excess — to conf.apf's CLIMIT lists // A per-source connection cap on a single address-less inbound tcp port,
// rather than the hook (isConnLimitRule). apf now also routes every other // rejecting the excess. The shared connlimit probe stops at the first form
// connection-limit shape to the hook (needsHook), so the shared // a backend accepts, which hides this one wherever a global form matched
// connlimit probe matches apf on its global hook form first; round-trip the // first: on csf and apf it is the single NATIVE connection-limit shape
// native form here to keep the conf.apf CLIMIT path covered. Gated to apf. // (csf.conf CONNLIMIT / conf.apf CLIMIT) while every other shape routes to
if mgr.Type() != APFType { // their hook, and nft counts it in a named meter set. Every
t.Skip("apf-specific conf.apf CLIMIT path") // connlimit-capable backend runs it; pf alone skips via the ErrUnsupported
} // sentinel (its max-src-conn applies only to a pass rule, so a rejecting
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}) // per-source cap has no pf form).
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
}) })
t.Run("csfdenyremovedanyaction", func(t *testing.T) { t.Run("denyremovedanyaction", func(t *testing.T) {
// A csf.deny entry carries no action of its own — csf applies csf.conf's action requireCap(t, caps.DenyActionFromConfig)
// by direction — so the deny of an address is a single entry there and must be // On a backend whose native deny store carries no per-entry action (the
// removable whatever action the caller names, as apf's deny_hosts entry is. // tool applies its config's action — see DenyActionFromConfig), the deny of
// Otherwise RemoveRule reports success while csf keeps enforcing the entry. // an address is a single entry (csf.deny, apf deny_hosts.rules) and must be
// Gated to csf; the stock csf.conf DROP is "DROP", so Drop is the native inbound // removable whatever action the caller names. Otherwise RemoveRule reports
// deny action and Reject is the differing one. // success while the tool keeps enforcing the entry. The stock config action
if mgr.Type() != CSFType { // is DROP on both, so Drop is the native deny action and Reject the
t.Skip("csf-specific csf.deny action-agnostic removal") // differing one.
}
host := "192.0.2.72/32" host := "192.0.2.72/32"
added := &Rule{Family: IPv4, Proto: TCP, Port: 8084, Source: host, Action: Drop} added := &Rule{Family: IPv4, Proto: TCP, Port: 8084, Source: host, Action: Drop}
require.NoError(t, mgr.AddRule(ctx, zone, added)) require.NoError(t, mgr.AddRule(ctx, zone, added))
@ -1142,23 +1216,10 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
require.NoError(t, mgr.RemoveRule(ctx, zone, &differing)) require.NoError(t, mgr.RemoveRule(ctx, zone, &differing))
for _, r := range rulesOf(t, ctx, mgr, zone) { for _, r := range rulesOf(t, ctx, mgr, zone) {
require.Falsef(t, addrEqual(r.Source, host) && r.Port == 8084, require.Falsef(t, addrEqual(r.Source, host) && r.Port == 8084,
"the csf.deny entry must be removed whatever action the target names: %+v", r) "the deny entry must be removed whatever action the target names: %+v", r)
} }
}) })
t.Run("csfnativeconnlimit", func(t *testing.T) {
// csf writes a native connection limit — a single inbound tcp port, no address,
// per-source, rejecting the excess — to csf.conf's CONNLIMIT list rather than
// the hook (isConnLimitRule). Every other connection-limit shape now routes to
// the hook (needsHook), so the shared connlimit probe matches csf on its global
// hook form first; round-trip the native form here to keep the CONNLIMIT path
// covered. Gated to csf.
if mgr.Type() != CSFType {
t.Skip("csf-specific csf.conf CONNLIMIT path")
}
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8082, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
})
t.Run("comment", func(t *testing.T) { t.Run("comment", func(t *testing.T) {
requireCap(t, caps.Comments) requireCap(t, caps.Comments)
// Most backends carry a comment on a bare port rule; CSF and APF can only // Most backends carry a comment on a bare port rule; CSF and APF can only
@ -1532,6 +1593,20 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), setRule, mgr.Capabilities().Output), require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), setRule, mgr.Capabilities().Output),
"set-matching rule still present after removal") "set-matching rule still present after removal")
// A FamilyAny set reference is accepted and pinned to the set's own family
// rather than rejected: the set is family-typed, so the rule could never
// match the other family anyway. containsRule compares family-agnostically,
// so the pinned read-back still satisfies the FamilyAny target, and the
// same target removes it.
anyRule := &Rule{Proto: TCP, Port: 4202, Source: set.Name, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, anyRule),
"a FamilyAny set reference must be pinned to the set's family, not rejected")
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), anyRule, mgr.Capabilities().Output),
"FamilyAny rule matching on set %q not found", set.Name)
require.NoError(t, mgr.RemoveRule(ctx, zone, anyRule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), anyRule, mgr.Capabilities().Output),
"FamilyAny set-matching rule still present after removal")
require.NoError(t, mgr.RemoveAddressSetEntry(ctx, set.Name, "192.0.2.10")) require.NoError(t, mgr.RemoveAddressSetEntry(ctx, set.Name, "192.0.2.10"))
sets, err = mgr.GetAddressSets(ctx) sets, err = mgr.GetAddressSets(ctx)
require.NoError(t, err) require.NoError(t, err)
@ -1575,6 +1650,88 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
require.NoError(t, mgr.RemoveAddressSet(ctx, inuse.Name)) require.NoError(t, mgr.RemoveAddressSet(ctx, inuse.Name))
}) })
// --- sync -------------------------------------------------------------------
t.Run("sync", func(t *testing.T) {
// Sync reconciles the zone toward a desired set. Desired is built as the
// CURRENT state plus two new rules, so the test never strips rules the
// environment depends on (a run may arrive over SSH), and the three
// contract points run end-to-end: missing rules are added, a second Sync
// is a no-op whatever rows the backend fanned the rules into (the diff is
// the coverage relation, not row equality), and a rule outside desired —
// freshly added here, but a foreign rule is reconciled the same way — is
// removed.
r1 := &Rule{Proto: TCP, Port: 5601, Action: Accept}
r2 := &Rule{Proto: TCP, Port: 5602, Action: Accept}
t.Cleanup(func() {
_ = mgr.RemoveRule(ctx, zone, r1)
_ = mgr.RemoveRule(ctx, zone, r2)
})
desired := append(rulesOf(t, ctx, mgr, zone), r1, r2)
added, removed, err := Sync(ctx, mgr, zone, desired)
require.NoError(t, err)
require.Equal(t, 2, added, "Sync must add exactly the two missing rules")
require.Zero(t, removed, "Sync must keep every rule desired covers")
rules := rulesOf(t, ctx, mgr, zone)
require.True(t, containsRule(rules, r1, caps.Output), "r1 missing after Sync")
require.True(t, containsRule(rules, r2, caps.Output), "r2 missing after Sync")
// Sync against its own output is a no-op, whichever rows the backend chose
// to store the rules as.
added, removed, err = Sync(ctx, mgr, zone, desired)
require.NoError(t, err)
require.Zero(t, added, "a second Sync must add nothing, or every run churns")
require.Zero(t, removed, "a second Sync must remove nothing, or every run churns")
// A rule desired does not cover is removed.
r3 := &Rule{Proto: TCP, Port: 5603, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, r3))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r3) })
added, removed, err = Sync(ctx, mgr, zone, desired)
require.NoError(t, err)
require.Zero(t, added, "reconciling away an undesired rule must not re-add anything")
require.NotZero(t, removed, "Sync must remove the rule desired does not cover")
rules = rulesOf(t, ctx, mgr, zone)
require.False(t, containsRule(rules, r3, caps.Output), "undesired rule still present after Sync")
require.True(t, containsRule(rules, r1, caps.Output), "r1 must survive the reconcile")
require.True(t, containsRule(rules, r2, caps.Output), "r2 must survive the reconcile")
})
t.Run("replacerules", func(t *testing.T) {
// ReplaceRulesBatch applies a full desired set in one shot — nft loads one
// script, iptables one restore file, pf its anchor — so the managed rule
// space becomes exactly the given set. Only the batching backends carry
// the method; everywhere else ReplaceRules falls back to Sync, which the
// sync subtest covers non-destructively (running this probe's small
// desired set through Sync would strip rules the environment depends on).
b, ok := mgr.(RuleBatcher)
if !ok {
t.Skip("backend has no batch replace; ReplaceRules falls back to Sync (covered by the sync subtest)")
}
r1 := &Rule{Proto: TCP, Port: 5604, Action: Accept}
r2 := &Rule{Proto: TCP, Port: 5605, Action: Accept}
t.Cleanup(func() {
_ = mgr.RemoveRule(ctx, zone, r1)
_ = mgr.RemoveRule(ctx, zone, r2)
})
require.NoError(t, b.ReplaceRulesBatch(ctx, zone, []*Rule{r1, r2}))
rules := rulesOf(t, ctx, mgr, zone)
require.True(t, containsRule(rules, r1, caps.Output), "r1 missing after batch replace")
require.True(t, containsRule(rules, r2, caps.Output), "r2 missing after batch replace")
// Replacing with a narrower set drops what it omits.
require.NoError(t, b.ReplaceRulesBatch(ctx, zone, []*Rule{r1}))
rules = rulesOf(t, ctx, mgr, zone)
require.True(t, containsRule(rules, r1, caps.Output), "r1 must survive the narrower replace")
require.False(t, containsRule(rules, r2, caps.Output), "r2 must be dropped by the narrower replace")
// An empty set clears the managed rules.
require.NoError(t, b.ReplaceRulesBatch(ctx, zone, nil))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), r1, caps.Output),
"replacing with an empty set must clear the managed rules")
})
// --- backup / restore ----------------------------------------------------- // --- backup / restore -----------------------------------------------------
t.Run("backup", func(t *testing.T) { t.Run("backup", func(t *testing.T) {
@ -1622,10 +1779,11 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
// forward order while AddRule prepends, silently reversing them — so a // forward order while AddRule prepends, silently reversing them — so a
// specific deny backed up above a broad allow came back below it and never // specific deny backed up above a broad allow came back below it and never
// fired. Compare each backend against itself: the order right after the adds // fired. Compare each backend against itself: the order right after the adds
// must equal the order after a backup/remove/restore cycle. // must equal the order after a backup/remove/restore cycle. Gated on
if !orderedFilterBackend(mgr.Type()) { // RuleOrdering: a backend whose filter rules form a first-match chain
t.Skip("backend has no first-match chain order to preserve") // advertises it, while the list/zone-model backends (csf, apf, firewalld,
} // wf) do not order rules this way.
requireCap(t, caps.RuleOrdering)
ports := []uint16{4101, 4102, 4103} ports := []uint16{4101, 4102, 4103}
var rules []*Rule var rules []*Rule
for _, p := range ports { for _, p := range ports {
@ -1714,139 +1872,201 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
} }
}) })
t.Run("profilescope", func(t *testing.T) { t.Run("zones", func(t *testing.T) {
// The Windows Filtering Platform maps a zone to a firewall profile; AddRule requireCap(t, caps.Zones)
// and GetRules scope to it, so RemoveRule must too. Removing a rule from one // A zones backend maps interfaces to zones: GetZone with no interface
// profile must not delete an identical rule in another. Only the wf backend // names the default zone, an interface bound to another zone (seeded out
// models per-zone profiles this way; the others share one rule space. // of band, permanent config only) resolves to that zone, and an unbound
if mgr.Type() != "windows-firewall" { // interface keeps resolving to the default. The binding targets the
t.Skip("backend does not scope rules to per-zone profiles") // trusted zone so an accidental runtime activation cannot filter
// anything away.
def, err := mgr.GetZone(ctx, "")
require.NoError(t, err)
require.NotEmpty(t, def, "a zones backend must name its default zone")
seeder := zoneInterfaceSeeder(mgr)
if seeder == nil {
t.Skip("no out-of-band interface-to-zone seeder for this backend")
} }
r := &Rule{Proto: TCP, Port: 5303, Action: Accept} target := "trusted"
require.NoError(t, mgr.AddRule(ctx, "public", r)) if def == target {
require.NoError(t, mgr.AddRule(ctx, "private", r)) target = "work"
t.Cleanup(func() { }
_ = mgr.RemoveRule(ctx, "public", r) undo, err := seeder("lo", target)
_ = mgr.RemoveRule(ctx, "private", r) if err != nil {
t.Skipf("could not bind an interface to zone %q: %v", target, err)
}
t.Cleanup(undo)
got, err := mgr.GetZone(ctx, "lo")
require.NoError(t, err)
require.Equal(t, target, got, "GetZone must resolve a bound interface to its zone")
unbound, err := mgr.GetZone(ctx, "gofwit0")
require.NoError(t, err)
require.Equal(t, def, unbound, "an unbound interface must resolve to the default zone")
}) })
pub, err := mgr.GetRules(ctx, "public") t.Run("rulecounters", func(t *testing.T) {
require.NoError(t, err) requireCap(t, caps.RuleCounters)
require.True(t, containsRule(pub, r, mgr.Capabilities().Output), "rule should be present in the public profile") // A backend advertising RuleCounters must populate Packets/Bytes from the
priv, err := mgr.GetRules(ctx, "private") // live firewall. Drive real traffic through a managed rule: an egress
require.NoError(t, err) // accept toward a TEST-NET address, dialed with a short timeout — the SYN
require.True(t, containsRule(priv, r, mgr.Capabilities().Output), "rule should be present in the private profile") // leaves through the real interface, so it traverses the egress path even
// where loopback is exempt from filtering (the FreeBSD harness pf.conf
// skips lo0). Counting on an egress rule needs the direction, and every
// RuleCounters backend also distinguishes output.
requireCap(t, caps.Output)
r := &Rule{Direction: DirOutput, Family: IPv4, Proto: TCP, Port: 39321, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, r))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
// Activate the rule where the backend programs the kernel on reload
// (iptables' save files load through its restore service).
require.NoError(t, mgr.Reload(ctx))
// Remove from the private profile only; the public copy must survive. counted := func() bool {
require.NoError(t, mgr.RemoveRule(ctx, "private", r)) for _, got := range rulesOf(t, ctx, mgr, zone) {
priv, err = mgr.GetRules(ctx, "private") if got.EqualBase(r, mgr.Capabilities().Output) && got.Packets > 0 {
require.NoError(t, err) return true
require.False(t, containsRule(priv, r, mgr.Capabilities().Output), "rule should be gone from the private profile") }
pub, err = mgr.GetRules(ctx, "public") }
require.NoError(t, err) return false
require.True(t, containsRule(pub, r, mgr.Capabilities().Output), "removing from the private profile must not delete the public rule") }
// The dial itself fails — nothing answers a TEST-NET address — and only
// the attempt matters: each SYN that leaves must hit the rule's counter.
// Retry until the deadline, since a backend may report counters with some
// latency.
deadline := time.Now().Add(10 * time.Second)
for !counted() {
conn, derr := net.DialTimeout("tcp", "192.0.2.1:39321", 500*time.Millisecond)
if derr == nil {
_ = conn.Close()
}
require.False(t, time.Now().After(deadline),
"the managed rule's Packets counter never became non-zero after driving traffic at it")
}
})
t.Run("zonescope", func(t *testing.T) {
// A zone-scoped backend keeps a separate rule space per zone — firewalld's
// zones, wf's firewall profiles. AddRule and GetRules scope to the named
// zone, so RemoveRule must too: removing a rule from one zone must not
// delete an identical rule in another. The scoping is probed rather than
// gated on a backend: the first candidate zone name the backend accepts is
// the base, and a second qualifies only if the base zone's rule is not
// already visible there — a backend with one shared rule space (everything
// but firewalld and wf) never finds a distinct second zone and skips.
r := &Rule{Proto: TCP, Port: 5303, Action: Accept}
candidates := []string{"public", "private", "home", "work", "domain", "internal", "dmz"}
var zoneA, zoneB string
for i, z := range candidates {
if err := mgr.AddRule(ctx, z, r); err != nil {
continue
}
zoneA = z
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zoneA, r) })
for _, z2 := range candidates[i+1:] {
rs, err := mgr.GetRules(ctx, z2)
if err != nil || containsRule(rs, r, caps.Output) {
continue // an unknown zone, or one sharing zoneA's rule space.
}
if err := mgr.AddRule(ctx, z2, r); err != nil {
continue
}
zoneB = z2
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zoneB, r) })
break
}
break
}
if zoneA == "" || zoneB == "" {
t.Skip("backend does not scope rules to two distinct zones")
}
require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneA), r, caps.Output), "rule should be present in zone %q", zoneA)
require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneB), r, caps.Output), "rule should be present in zone %q", zoneB)
// Remove from the second zone only; the first zone's copy must survive.
require.NoError(t, mgr.RemoveRule(ctx, zoneB, r))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zoneB), r, caps.Output), "rule should be gone from zone %q", zoneB)
require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneA), r, caps.Output),
"removing the rule from zone %q must not delete the copy in zone %q", zoneB, zoneA)
}) })
t.Run("foreignmacsource", func(t *testing.T) { t.Run("foreignmacsource", func(t *testing.T) {
// firewalld stores a MAC (or ipset) zone source, which GetRules reports as a // A backend may store a MAC zone source (firewalld), which GetRules reports
// bare-source rule. RemoveRule's source shortcut previously only handled an // as a bare-source rule. RemoveRule's source shortcut previously only handled
// IP/CIDR, so a foreign MAC source could never be removed — Sync/Restore could // an IP/CIDR, so a foreign MAC source could never be removed — Sync/Restore
// not converge on it. Seed one out of band in the permanent config the backend // could not converge on it. Seed one out of band in the permanent config the
// reads and confirm the library both surfaces and removes it. Only firewalld // backend reads and confirm the library both surfaces and removes it. The
// models MAC/ipset zone sources this way. // generic foreign-rule sweep is the foreignrule subtest; the seeding
if mgr.Type() != "firewalld" { // commands are inherently backend-specific (foreignMACSeeder), so a backend
t.Skip("backend does not model MAC/ipset zone sources") // without a seeder skips, exactly like foreignrule.
seeder := foreignMACSeeder(mgr)
if seeder == nil {
t.Skip("no out-of-band MAC zone-source seeder for this backend")
} }
const mac = "00:11:22:33:44:55" seed, err := seeder(zone)
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-source="+mac).CombinedOutput(); err != nil { if err != nil {
t.Skipf("could not seed a foreign MAC source (firewall-cmd: %v): %s", err, out) t.Skipf("could not seed a foreign MAC source: %v", err)
} }
t.Cleanup(func() { t.Cleanup(seed.undo)
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-source="+mac).Run()
})
want := &Rule{Source: mac, Action: Accept} require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
"a foreign MAC zone source should surface in GetRules") "a foreign MAC zone source should surface in GetRules")
require.NoError(t, mgr.RemoveRule(ctx, zone, want)) require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output), require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
"RemoveRule must remove a foreign MAC zone source") "RemoveRule must remove a foreign MAC zone source")
}) })
t.Run("foreignprotocol", func(t *testing.T) { t.Run("foreignprotocol", func(t *testing.T) {
// firewalld stores a bare-protocol allow (firewall-cmd --add-protocol) as a // A backend may store a bare-protocol allow as its own construct distinct
// zone protocol entry, distinct from the rich-rule form the library writes. // from the rule form the library writes (firewalld's zone protocol entries,
// GetRules must surface it and RemoveRule must remove it, or a foreign // seeded with firewall-cmd --add-protocol). GetRules must surface it and
// --add-protocol is invisible to Sync/Restore. Only firewalld models this. // RemoveRule must remove it, or a foreign protocol allow is invisible to
if mgr.Type() != "firewalld" { // Sync/Restore. Like foreignmacsource, the seeding is backend-specific
t.Skip("backend does not model zone protocol entries") // (foreignProtocolSeeder) and a backend without a seeder skips.
seeder := foreignProtocolSeeder(mgr)
if seeder == nil {
t.Skip("no out-of-band protocol-entry seeder for this backend")
} }
const proto = "gre" seed, err := seeder(zone)
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-protocol="+proto).CombinedOutput(); err != nil { if err != nil {
t.Skipf("could not seed a foreign protocol (firewall-cmd: %v): %s", err, out) t.Skipf("could not seed a foreign protocol: %v", err)
} }
t.Cleanup(func() { t.Cleanup(seed.undo)
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-protocol="+proto).Run()
})
want := &Rule{Proto: GRE, Action: Accept} require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
"a foreign zone protocol should surface in GetRules") "a foreign zone protocol should surface in GetRules")
require.NoError(t, mgr.RemoveRule(ctx, zone, want)) require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output), require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
"RemoveRule must remove a foreign zone protocol") "RemoveRule must remove a foreign zone protocol")
}) })
t.Run("afterrulesexcluded", func(t *testing.T) { t.Run("afterrulesexcluded", func(t *testing.T) {
// ufw writes and removes raw rules only in before.rules, so GetRules must not // A backend may keep a raw rules file it deliberately does not manage (ufw's
// surface a rule from after.rules — otherwise Backup captures it and Restore // after.rules; the library writes raw rules only into before.rules). GetRules
// re-adds it into before.rules, duplicating it. Only the ufw backend has this // must not surface a rule from it — otherwise Backup captures it and Restore
// before/after split. // re-adds it into the managed file, duplicating it. The seeding edits the
if mgr.Type() != "ufw" { // backend's own file (unmanagedRawRuleSeeder), so a backend without one skips.
t.Skip("backend has no after.rules file") seeder := unmanagedRawRuleSeeder(mgr)
if seeder == nil {
t.Skip("backend has no unmanaged raw rules file to seed")
} }
const afterPath = "/etc/ufw/after.rules" probe, undo, err := seeder()
orig, err := os.ReadFile(afterPath)
if err != nil { if err != nil {
t.Skipf("cannot read after.rules: %v", err) t.Skipf("could not seed the unmanaged raw rule: %v", err)
} }
t.Cleanup(func() { _ = os.WriteFile(afterPath, orig, 0o640) }) t.Cleanup(undo)
// Inject a parseable rule into the ufw-after-input chain, before the real
// COMMIT directive. after.rules carries a "# don't delete the 'COMMIT' line"
// comment, so match the standalone COMMIT line rather than the first literal.
lines := strings.Split(string(orig), "\n")
placed := false
for i, l := range lines {
if strings.TrimSpace(l) == "COMMIT" {
lines = append(lines[:i:i], append([]string{"-A ufw-after-input -p tcp -m tcp --dport 8765 -j ACCEPT"}, lines[i:]...)...)
placed = true
break
}
}
require.True(t, placed, "after.rules should have a COMMIT directive to inject before")
require.NoError(t, os.WriteFile(afterPath, []byte(strings.Join(lines, "\n")), 0o640))
probe := &Rule{Family: IPv4, Proto: TCP, Port: 8765, Action: Accept}
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), probe, mgr.Capabilities().Output), require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), probe, mgr.Capabilities().Output),
"a rule in after.rules must not be surfaced by GetRules") "a rule in an unmanaged raw rules file must not be surfaced by GetRules")
}) })
} }
// orderedFilterBackend reports whether a backend's filter rules form a first-match
// chain whose order Restore must reproduce. The list/zone-model backends (csf, apf,
// firewalld, wf) do not order rules this way, so the restoreorder check is skipped
// for them.
func orderedFilterBackend(typ string) bool {
switch typ {
case "nftables", "iptables", "ufw", "pf":
return true
}
return false
}
// --- helpers ---------------------------------------------------------------- // --- helpers ----------------------------------------------------------------
// requireCap skips the current subtest when the backend does not advertise the // requireCap skips the current subtest when the backend does not advertise the
@ -1863,6 +2083,28 @@ func requireCap(t *testing.T, supported bool) {
func roundTripRule(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) { func roundTripRule(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
t.Helper() t.Helper()
require.NoError(t, mgr.AddRule(ctx, zone, rule)) require.NoError(t, mgr.AddRule(ctx, zone, rule))
roundTripAdded(t, ctx, mgr, zone, rule)
}
// roundTripRuleOrSkip is roundTripRule for a shape not every backend can express:
// a rejection with the ErrUnsupported sentinel skips the subtest instead of
// failing it. A skip marks a real expressiveness gap — a feature the backend
// might expose (or a capability that should advertise it) — while any other
// failure is still a bug.
func roundTripRuleOrSkip(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
t.Helper()
err := mgr.AddRule(ctx, zone, rule)
if errors.Is(err, ErrUnsupported) {
t.Skipf("backend cannot express this shape: %v", err)
}
require.NoError(t, err)
roundTripAdded(t, ctx, mgr, zone, rule)
}
// roundTripAdded finishes a round trip for a rule AddRule already accepted: it
// must read back, remove, and read back as gone.
func roundTripAdded(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
t.Helper()
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
rules := rulesOf(t, ctx, mgr, zone) rules := rulesOf(t, ctx, mgr, zone)
@ -1880,6 +2122,26 @@ func roundTripRule(t *testing.T, ctx context.Context, mgr Manager, zone string,
// requires a type. It fails if the backend rejects every form as unsupported, // requires a type. It fails if the backend rejects every form as unsupported,
// since the capability under test claimed the feature works. // since the capability under test claimed the feature works.
func roundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) { func roundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) {
t.Helper()
if !tryRoundTripVariants(t, ctx, mgr, zone, variants...) {
t.Fatal("backend advertised the capability but rejected every probe form as unsupported")
}
}
// roundTripVariantsOrSkip is roundTripVariants for a shape family no capability
// guards: a backend that rejects every form with the ErrUnsupported sentinel
// skips the subtest instead of failing it.
func roundTripVariantsOrSkip(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) {
t.Helper()
if !tryRoundTripVariants(t, ctx, mgr, zone, variants...) {
t.Skip("backend cannot express this shape in any probed form")
}
}
// tryRoundTripVariants round-trips the first variant the backend accepts and
// reports whether any was accepted. A variant rejected with an ErrUnsupported
// sentinel moves on to the next; any other add error fails the test.
func tryRoundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) bool {
t.Helper() t.Helper()
for _, rule := range variants { for _, rule := range variants {
err := mgr.AddRule(ctx, zone, rule) err := mgr.AddRule(ctx, zone, rule)
@ -1887,15 +2149,10 @@ func roundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone stri
continue // the backend cannot express this form; try the next. continue // the backend cannot express this form; try the next.
} }
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) roundTripAdded(t, ctx, mgr, zone, rule)
return true
rules := rulesOf(t, ctx, mgr, zone)
require.True(t, containsRule(rules, rule, mgr.Capabilities().Output), "added rule %+v not found in %s", rule, dumpRules(rules))
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output), "rule still present after removal")
return
} }
t.Fatal("backend advertised the capability but rejected every probe form as unsupported") return false
} }
// roundTripNATVariants tries each NAT rule form and round-trips the first the // roundTripNATVariants tries each NAT rule form and round-trips the first the

View file

@ -84,7 +84,7 @@ func probeDebianLayout(root string) (l iptLayout, ok bool) {
// probeRHELLayout reports the RHEL/iptables-services layout // probeRHELLayout reports the RHEL/iptables-services layout
// (/etc/sysconfig/iptables and ip6tables, restored by the iptables and // (/etc/sysconfig/iptables and ip6tables, restored by the iptables and
// ip6tables services) if its save files are both present under root. It does // ip6tables services) if its save files are both present under root. The
// caller must have already confirmed the RHEL v4 path exists before treating // caller must have already confirmed the RHEL v4 path exists before treating
// an incomplete pair (ok=false) as fatal rather than falling through to // an incomplete pair (ok=false) as fatal rather than falling through to
// another layout. root is prepended to both paths so tests can point the // another layout. root is prepended to both paths so tests can point the
@ -111,9 +111,9 @@ func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) {
// Prefer the RHEL layout when its v4 save file is present. A v4 file with // Prefer the RHEL layout when its v4 save file is present. A v4 file with
// no v6 partner is still a fatal IPTablesNoSave (not a signal to try the // no v6 partner is still a fatal IPTablesNoSave (not a signal to try the
// Debian layout) — we do not know what may be used for the firewall in that // Debian layout), since what manages the firewall in that case is unknown.
// case. Only when the RHEL v4 path is entirely absent do we probe the // Only when the RHEL v4 path is entirely absent is the Debian
// Debian iptables-persistent layout instead. // iptables-persistent layout probed instead.
var layout iptLayout var layout iptLayout
if _, err := os.Stat("/etc/sysconfig/iptables"); err == nil { if _, err := os.Stat("/etc/sysconfig/iptables"); err == nil {
l, ok := probeRHELLayout("") l, ok := probeRHELLayout("")
@ -137,8 +137,8 @@ func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) {
return nil, errors.New(IPTablesNoService) return nil, errors.New(IPTablesNoService)
} }
// If ip6tables service is missing, we do not want to modify iptables // A missing ip6tables service means something unknown manages the v6
// as we do not know what may be used for the firewall. Skip the redundant // firewall, so iptables must not be modified either. Skip the redundant
// check when it is the same service already confirmed enabled above (the // check when it is the same service already confirmed enabled above (the
// Debian layout uses one service for both families). // Debian layout uses one service for both families).
ipt.IP6Service = layout.ip6Service ipt.IP6Service = layout.ip6Service
@ -168,6 +168,7 @@ func (f *IPTables) Capabilities() Capabilities {
Forward: true, Forward: true,
ICMPv6: true, ICMPv6: true,
PortList: true, PortList: true,
PortPair: true,
ConnState: true, ConnState: true,
InterfaceMatch: true, InterfaceMatch: true,
Logging: true, Logging: true,
@ -179,6 +180,9 @@ func (f *IPTables) Capabilities() Capabilities {
RuleCounters: true, RuleCounters: true,
AddressSets: true, AddressSets: true,
Comments: true, Comments: true,
Negation: true,
RejectAction: true,
FamilyWithoutAddress: true,
} }
} }
@ -306,7 +310,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
// Continue so the negation is not cleared before the match token is read. // Continue so the negation is not cleared before the match token is read.
continue continue
case "-p", "--protocol": case "-p", "--protocol":
// We do not support negation on this parameter. // Negation is unsupported on this parameter.
if not { if not {
return nil, fmt.Errorf("negation is defined for protocol, which our limited rule structure does not support") return nil, fmt.Errorf("negation is defined for protocol, which our limited rule structure does not support")
} }
@ -328,7 +332,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
return nil, fmt.Errorf("invalid source parameter") return nil, fmt.Errorf("invalid source parameter")
} }
// Confirm we can parse the address. // Confirm the address parses.
_, _, err := net.ParseCIDR(tokens[i]) _, _, err := net.ParseCIDR(tokens[i])
ip := net.ParseIP(tokens[i]) ip := net.ParseIP(tokens[i])
if err != nil && ip == nil { if err != nil && ip == nil {
@ -347,7 +351,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
return nil, fmt.Errorf("invalid destination parameter") return nil, fmt.Errorf("invalid destination parameter")
} }
// Confirm we can parse the address. // Confirm the address parses.
_, _, err := net.ParseCIDR(tokens[i]) _, _, err := net.ParseCIDR(tokens[i])
ip := net.ParseIP(tokens[i]) ip := net.ParseIP(tokens[i])
if err != nil && ip == nil { if err != nil && ip == nil {
@ -434,7 +438,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
} }
r.OutInterface = tokens[i] r.OutInterface = tokens[i]
case "-j", "--jump": case "-j", "--jump":
// We do not support negation on this parameter. // Negation is unsupported on this parameter.
if not { if not {
return nil, fmt.Errorf("negation is defined for jump, which our limited rule structure does not support") return nil, fmt.Errorf("negation is defined for jump, which our limited rule structure does not support")
} }
@ -450,6 +454,11 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
r.Action = Drop r.Action = Drop
case "REJECT": case "REJECT":
r.Action = Reject r.Action = Reject
// The Rule model carries no reject type, so a `--reject-with` detail is
// consumed and a rewrite re-emits a plain REJECT (iptables' default
// icmp-port-unreachable). Rejecting the line instead would leave common
// stock rules (RHEL's icmp-host-prohibited REJECT) foreign and
// unmanageable, which costs more than the reject-code drift.
if i+2 < len(tokens) { if i+2 < len(tokens) {
if tokens[i+1] == "--reject-with" { if tokens[i+1] == "--reject-with" {
i += 2 i += 2
@ -476,7 +485,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
return nil, fmt.Errorf("unsupported jump option: %s", tokens[i]) return nil, fmt.Errorf("unsupported jump option: %s", tokens[i])
} }
case "-m", "--match": case "-m", "--match":
// We do not support negation on this parameter (the set match negates // Negation is unsupported on this parameter (the set match negates
// internally, after `-m set`, so it is handled inside its case). // internally, after `-m set`, so it is handled inside its case).
if not { if not {
return nil, fmt.Errorf("negation is defined for match, which our limited rule structure does not support") return nil, fmt.Errorf("negation is defined for match, which our limited rule structure does not support")
@ -627,12 +636,15 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
r.ICMPType = Ptr(n) r.ICMPType = Ptr(n)
} }
case "multiport": case "multiport":
// -m multiport --dports/--sports 80,443,1000:2000 (also --ports). // -m multiport --dports/--sports 80,443,1000:2000. `--ports`/`--port`
// means source OR destination port, which the model cannot hold —
// mapping it onto one side would silently drop the other half on a
// re-marshal — so such a line stays foreign.
if i+2 >= len(tokens) { if i+2 >= len(tokens) {
return nil, fmt.Errorf("invalid multiport match") return nil, fmt.Errorf("invalid multiport match")
} }
switch tokens[i+1] { switch tokens[i+1] {
case "--dports", "--dport", "--sports", "--sport", "--ports", "--port": case "--dports", "--dport", "--sports", "--sport":
default: default:
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1]) return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
} }
@ -656,7 +668,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
} }
} }
case "tcp": case "tcp":
// Invalid protocol define. // Reject an unknown protocol token.
if r.Proto == UDP { if r.Proto == UDP {
return nil, fmt.Errorf("specifying TCP options for UDP") return nil, fmt.Errorf("specifying TCP options for UDP")
} }
@ -713,7 +725,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
case "udp", "sctp": case "udp", "sctp":
// SCTP carries ports like UDP and iptables-save spells its port // SCTP carries ports like UDP and iptables-save spells its port
// match module `-m sctp`, so it shares this branch. // match module `-m sctp`, so it shares this branch.
// Invalid protocol define. // Reject an unknown protocol token.
if r.Proto == TCP { if r.Proto == TCP {
return nil, fmt.Errorf("specifying UDP options for TCP") return nil, fmt.Errorf("specifying UDP options for TCP")
} }
@ -775,7 +787,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
return nil, fmt.Errorf("unsupported option: %s", tokens[i]) return nil, fmt.Errorf("unsupported option: %s", tokens[i])
} }
// As rule has been parsed, we may now mark not as false. // The token consumed any pending negation; clear it for the next one.
not = false not = false
} }
@ -861,7 +873,11 @@ func coalesceLoggedRules(rules []*Rule) []*Rule {
// parseFilterFile reads a family's iptables-save file and returns its filter // parseFilterFile reads a family's iptables-save file and returns its filter
// rules as logical rules, coalescing each LOG line with the action line that // rules as logical rules, coalescing each LOG line with the action line that
// follows it. Lines that do not parse (nat rules, custom chains) are skipped. // follows it. Lines that do not parse (nat rules, custom chains, unmodeled
// foreign matches) are skipped, but an unmodeled line still holds its physical
// slot during coalescing: a LOG line and an action line separated by a foreign
// line are NOT one logged rule — extractRuleLines and logicalStarts see them
// apart, so pairing them here would report a rule no removal could ever find.
func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) { func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) {
fd, err := os.Open(path) fd, err := os.Open(path)
if err != nil { if err != nil {
@ -895,6 +911,10 @@ func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error)
} }
r, err := f.UnmarshalRule(line, family) r, err := f.UnmarshalRule(line, family)
if err != nil { if err != nil {
// Keep the unmodeled line's slot so LOG pairing cannot reach across it.
if strings.HasPrefix(f.ruleLineBody(line), "-A ") {
perLine = append(perLine, nil)
}
continue continue
} }
// UnmarshalRule already stripped the prefix from the comment and // UnmarshalRule already stripped the prefix from the comment and
@ -905,9 +925,23 @@ func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error)
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return nil, err return nil, err
} }
// Return the coalesced rules; GetRules assigns Number once both save files are // Coalesce LOG pairs positionally — exactly as logicalStarts numbers them —
// read (the other callers use the result only for dedup and never read Number). // then compact the unmodeled slots away; GetRules assigns Number once both
return coalesceLoggedRules(perLine), nil // save files are read (the other callers use the result only for dedup and
// never read Number).
starts := f.logicalStarts(perLine)
out := make([]*Rule, 0, len(perLine))
for i, r := range perLine {
if r == nil || starts[i] == 0 {
continue
}
if r.Action == ActionInvalid && r.Log {
out = append(out, mergeLogPair(r, perLine[i+1]))
continue
}
out = append(out, r)
}
return out, nil
} }
// GetRules returns the existing filter rules from the zone. // GetRules returns the existing filter rules from the zone.
@ -931,11 +965,62 @@ func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule
numberByDirection(v4) numberByDirection(v4)
numberByDirection(v6) numberByDirection(v6)
// The save files carry no packet/byte counters — the kernel does — so merge
// them from the live ruleset (RuleCounters).
f.mergeLiveCounters(ctx, v4, IPv4)
f.mergeLiveCounters(ctx, v6, IPv6)
rules = append(rules, v4...) rules = append(rules, v4...)
rules = append(rules, v6...) rules = append(rules, v6...)
return return
} }
// mergeLiveCounters copies the kernel's packet/byte counters onto the
// file-parsed rules, matching `iptables-save -c` lines to file rules by rule
// identity. It is best-effort: a missing save binary, a file edit not yet
// activated by Reload, or a rule absent from the kernel simply leaves that
// rule's counters zero. Counters are informational (RuleCounters) and never
// part of rule identity.
func (f *IPTables) mergeLiveCounters(ctx context.Context, rules []*Rule, fam Family) {
cmd := "iptables-save"
if fam == IPv6 {
cmd = "ip6tables-save"
}
out, err := runCommand(ctx, cmd, "-c", "-t", "filter")
if err != nil {
return
}
var live []*Rule
for _, line := range out {
line = strings.TrimSpace(line)
// Rule lines carry a leading [pkts:bytes] token; policy and table
// headers do not.
if !strings.HasPrefix(line, "[") {
continue
}
r, uerr := unmarshalIPTablesRule(line, fam)
if uerr != nil {
continue
}
live = append(live, r)
}
// Consume each live row at most once so duplicate rules keep distinct counts.
used := make([]bool, len(live))
for _, r := range rules {
if r == nil {
continue
}
for i, l := range live {
if used[i] || !l.Equal(r, true) {
continue
}
r.Packets, r.Bytes = l.Packets, l.Bytes
used[i] = true
break
}
}
}
// applyRuleFiles runs prepare against each family file the rule applies to, // applyRuleFiles runs prepare against each family file the rule applies to,
// staging all temp files first and committing them only once every file has // staging all temp files first and committing them only once every file has
// been prepared successfully. A FamilyAny rule touches both the IPv4 and IPv6 // been prepared successfully. A FamilyAny rule touches both the IPv4 and IPv6
@ -1082,11 +1167,11 @@ func iptChainForDirection(d Direction) string {
// logicalStarts maps each entry of rules (the parsed `-A` lines of one chain, // logicalStarts maps each entry of rules (the parsed `-A` lines of one chain,
// in file order) to the 1-based logical-rule position it begins, or 0 when it is // in file order) to the 1-based logical-rule position it begins, or 0 when it is
// not a logical-rule start — an action line coalesced into a preceding LOG line, // not a logical-rule start — an unmodeled (nil) foreign line, an action line
// or a dropped orphan LOG line. It mirrors coalesceLoggedRules exactly so an // coalesced into a preceding LOG line, or a dropped orphan LOG line. It mirrors
// insert/move position aligns with the per-chain numbering GetRules reports: a // GetRules' numbering exactly — Rule.Number promises to mirror the InsertRule/
// LOG line paired with its action is one logical rule beginning at the LOG line, // MoveRule position argument, and GetRules skips lines it cannot parse — so a
// while an orphan LOG line (no matching action after it) begins none. The // caller position never lands relative to a rule GetRules did not report. The
// returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and // returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and
// prepareMoveRuleFile can translate a caller position into a physical line. // prepareMoveRuleFile can translate a caller position into a physical line.
func (f *IPTables) logicalStarts(rules []*Rule) []int { func (f *IPTables) logicalStarts(rules []*Rule) []int {
@ -1094,7 +1179,12 @@ func (f *IPTables) logicalStarts(rules []*Rule) []int {
pos := 0 pos := 0
for i := 0; i < len(rules); i++ { for i := 0; i < len(rules); i++ {
cur := rules[i] cur := rules[i]
if cur != nil && cur.Action == ActionInvalid && cur.Log { // An unmodeled foreign line is invisible to GetRules, so it begins no
// logical rule; it stays pinned to the rule that follows it.
if cur == nil {
continue
}
if cur.Action == ActionInvalid && cur.Log {
var next *Rule var next *Rule
if i+1 < len(rules) { if i+1 < len(rules) {
next = rules[i+1] next = rules[i+1]
@ -1234,6 +1324,7 @@ func (f *IPTables) stateValue(s ConnState) string {
// marshalMatches builds the iptables-save match tokens for a rule (everything // marshalMatches builds the iptables-save match tokens for a rule (everything
// up to but not including the `-j <target>`), including any rate/connection // up to but not including the `-j <target>`), including any rate/connection
// limit and the identifying comment. MarshalRule and the LOG-line encoder share // limit and the identifying comment. MarshalRule and the LOG-line encoder share
// it so a logged rule's two lines carry identical match tokens.
func (f *IPTables) marshalMatches(r *Rule) ([]string, error) { func (f *IPTables) marshalMatches(r *Rule) ([]string, error) {
// A TCPUDP rule has no single-line iptables form; it must be fanned out into a // A TCPUDP rule has no single-line iptables form; it must be fanned out into a
// tcp row and a udp row before reaching this row-level marshaller. Assert the // tcp row and a udp row before reaching this row-level marshaller. Assert the
@ -1435,11 +1526,12 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
return nil, err return nil, err
} }
fd, err := os.Open(filePath) // Read the file once; the same lines feed both the position precomputation
// and the rewrite pass.
allLines, err := f.readAllLines(filePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { _ = fd.Close() }()
af, err := newAtomicFile(filePath, 0644) af, err := newAtomicFile(filePath, 0644)
if err != nil { if err != nil {
@ -1457,15 +1549,9 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
// Indexing this as the write pass scans keeps the insert aligned with the // Indexing this as the write pass scans keeps the insert aligned with the
// position GetRules reports and never splits a logged rule's two lines or a // position GetRules reports and never splits a logged rule's two lines or a
// fanned-out tcp/udp pair. // fanned-out tcp/udp pair.
allLines, err := f.readAllLines(filePath)
if err != nil {
af.Abort()
return nil, err
}
chainStarts := f.chainStarts(allLines, expectedChain) chainStarts := f.chainStarts(allLines, expectedChain)
chainIdx := 0 chainIdx := 0
scanner := bufio.NewScanner(fd)
filterFound := false filterFound := false
writtenRule := false writtenRule := false
writeRule := func() { writeRule := func() {
@ -1475,8 +1561,8 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
writtenRule = true writtenRule = true
} }
for scanner.Scan() { for _, raw := range allLines {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(raw)
if !filterFound { if !filterFound {
if line == "*filter" { if line == "*filter" {
@ -1504,16 +1590,9 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
_, _ = fmt.Fprintln(af, line) _, _ = fmt.Fprintln(af, line)
} }
// A read error means the staged file is truncated; discard it rather than
// installing a partial ruleset.
if err := scanner.Err(); err != nil {
af.Abort()
return nil, err
}
if !writtenRule { if !writtenRule {
af.Abort() af.Abort()
return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file") return nil, fmt.Errorf("failed to write the new rule to the iptables-save file")
} }
return af, nil return af, nil
@ -1706,7 +1785,7 @@ func (f *IPTables) prepareMoveRuleFile(filePath string, r *Rule, position int) (
} }
if !inserted { if !inserted {
return nil, fmt.Errorf("we were not able to move the rule in the iptables-save file") return nil, fmt.Errorf("failed to move the rule in the iptables-save file")
} }
return f.stageLines(filePath, out) return f.stageLines(filePath, out)
@ -1748,7 +1827,12 @@ func (f *IPTables) parseNATTarget(tok string) (addr string, port uint16) {
return tok, 0 return tok, 0
} }
// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING. // UnmarshalNATRule decodes one iptables-save nat line (-A PREROUTING / -A
// POSTROUTING ...) into a NATRule. A line the model cannot hold faithfully — a
// negated interface/protocol/port match, an unknown protocol, an unsupported
// jump, or another chain — is rejected so the raw line stays foreign and is
// preserved verbatim; an OUTPUT-chain DNAT, for example, has no distinct model
// here and would otherwise be relocated to PREROUTING on Restore.
func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error) { func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error) {
tokens, err := shlex.Split(spec, true) tokens, err := shlex.Split(spec, true)
if err != nil { if err != nil {
@ -1819,18 +1903,37 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error
r.Destination = tokens[i] r.Destination = tokens[i]
} }
case "-i", "--in-interface", "-o", "--out-interface": case "-i", "--in-interface", "-o", "--out-interface":
// Interface, protocol and port carry no negated form in the model, so
// a `!` here must reject the line — reading `! -o docker0` as a plain
// interface match would invert the rule's meaning (Docker's stock
// masquerade rule is exactly this shape).
if not {
return nil, fmt.Errorf("negated interface match is not modeled")
}
i++ i++
if i >= len(tokens) { if i >= len(tokens) {
return nil, fmt.Errorf("invalid interface parameter") return nil, fmt.Errorf("invalid interface parameter")
} }
r.Interface = tokens[i] r.Interface = tokens[i]
case "-p", "--protocol": case "-p", "--protocol":
if not {
return nil, fmt.Errorf("negated protocol match is not modeled")
}
i++ i++
if i >= len(tokens) { if i >= len(tokens) {
return nil, fmt.Errorf("invalid protocol parameter") return nil, fmt.Errorf("invalid protocol parameter")
} }
r.Proto = GetProtocol(tokens[i]) proto := GetProtocol(tokens[i])
if proto == ProtocolAny {
// An unknown protocol token must not silently widen to a
// match-every-protocol rule a Restore would re-marshal without -p.
return nil, fmt.Errorf("unsupported protocol: %s", tokens[i])
}
r.Proto = proto
case "--dport", "--destination-port": case "--dport", "--destination-port":
if not {
return nil, fmt.Errorf("negated port match is not modeled")
}
i++ i++
if i >= len(tokens) { if i >= len(tokens) {
return nil, fmt.Errorf("invalid dport parameter") return nil, fmt.Errorf("invalid dport parameter")
@ -1845,6 +1948,9 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error
r.Ports = []PortRange{pr} r.Ports = []PortRange{pr}
} }
case "-m", "--match": case "-m", "--match":
if not {
return nil, fmt.Errorf("negated match is not modeled")
}
i++ i++
if i >= len(tokens) { if i >= len(tokens) {
return nil, fmt.Errorf("invalid match parameter") return nil, fmt.Errorf("invalid match parameter")
@ -1904,8 +2010,11 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error
return nil, fmt.Errorf("invalid multiport match") return nil, fmt.Errorf("invalid multiport match")
} }
switch tokens[i+1] { switch tokens[i+1] {
case "--dports", "--dport", "--ports", "--port": case "--dports", "--dport":
default: default:
// `--ports`/`--port` means source OR destination port; mapping it
// onto the destination fields would silently drop the source half
// on a re-marshal, so the line stays foreign.
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1]) return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
} }
i += 2 i += 2
@ -2184,7 +2293,9 @@ func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) e
return f.writeAllLines(path, updated) return f.writeAllLines(path, updated)
} }
// Removal: drop the matching nat rule line. // Removal: drop every matching nat rule line, not just the first, so a chain
// holding duplicate equivalent lines comes clean in one call — mirroring the
// filter path's removal loop.
if natStart == -1 || commitIdx == -1 { if natStart == -1 || commitIdx == -1 {
return nil return nil
} }
@ -2203,7 +2314,7 @@ func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) e
updated = append(updated, raw) updated = append(updated, raw)
continue continue
} }
if inNat && !removed && !f.IgnoreLine(t) { if inNat && !f.IgnoreLine(t) {
if nr, perr := f.UnmarshalNATRule(t, f.fileFamily(path)); perr == nil && nr.EqualBase(r) { if nr, perr := f.UnmarshalNATRule(t, f.fileFamily(path)); perr == nil && nr.EqualBase(r) {
removed = true removed = true
continue continue
@ -2537,9 +2648,14 @@ func (f *IPTables) ipsetParseType(fields []string) (Family, SetType) {
func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
out, err := runCommand(ctx, "ipset", "save") out, err := runCommand(ctx, "ipset", "save")
if err != nil { if err != nil {
// ipset not installed, or no sets: nothing to report. // An absent ipset binary means no sets to report; any other failure
// (permission denial, a missing kernel module) must surface — reading it
// as "no sets" would let a Backup silently capture none.
if !commandExists("ipset") {
return nil, nil return nil, nil
} }
return nil, err
}
sets := map[string]*AddressSet{} sets := map[string]*AddressSet{}
var names []string var names []string
for _, line := range out { for _, line := range out {
@ -2552,7 +2668,9 @@ func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
} }
for _, line := range out { for _, line := range out {
fields := strings.Fields(line) fields := strings.Fields(line)
if len(fields) == 3 && fields[0] == "add" { // An add line may carry entry options (`timeout 600`, `comment "x"`);
// the entry itself is still the third field.
if len(fields) >= 3 && fields[0] == "add" {
if set, ok := sets[fields[1]]; ok { if set, ok := sets[fields[1]]; ok {
set.Entries = append(set.Entries, fields[2]) set.Entries = append(set.Entries, fields[2])
} }
@ -2841,6 +2959,7 @@ func (f *IPTables) rewriteFilterRules(path string, ruleLines []string) error {
// and writes (PREROUTING/POSTROUTING). rewriteNATRules replaces the rules in these // and writes (PREROUTING/POSTROUTING). rewriteNATRules replaces the rules in these
// chains and preserves every other nat chain verbatim — including OUTPUT, whose // chains and preserves every other nat chain verbatim — including OUTPUT, whose
// locally-generated DNAT the NATRule model cannot represent distinctly (see // locally-generated DNAT the NATRule model cannot represent distinctly (see
// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING.
func (f *IPTables) managedNATChain(chain string) bool { func (f *IPTables) managedNATChain(chain string) bool {
switch chain { switch chain {
case "PREROUTING", "POSTROUTING": case "PREROUTING", "POSTROUTING":
@ -2849,19 +2968,51 @@ func (f *IPTables) managedNATChain(chain string) bool {
return false return false
} }
// preservedNATLines returns the indices of managed-chain (-A PREROUTING /
// POSTROUTING) lines a rewrite must keep verbatim because UnmarshalNATRule
// cannot model them: an unsupported jump (-j DOCKER, -j RETURN), an unmodeled
// or negated match, a source-port match. Such a line is invisible to
// GetNATRules, so it never appears in the desired set and would otherwise be
// deleted — severing, for example, Docker's port publishing on a Backup/Restore
// round trip. It is the nat counterpart of preservedFilterLines.
func (f *IPTables) preservedNATLines(lines []string) map[int]bool {
preserved := map[int]bool{}
inNat := false
for i, line := range lines {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "*") {
inNat = t == "*nat"
continue
}
body := f.ruleLineBody(t)
if !inNat || !strings.HasPrefix(body, "-A ") {
continue
}
if !f.managedNATChain(f.chainOf(body)) {
continue
}
if _, err := f.UnmarshalNATRule(t, FamilyAny); err != nil {
preserved[i] = true
}
}
return preserved
}
// rewriteNATRules atomically rewrites path so that the *nat table's rule lines in // rewriteNATRules atomically rewrites path so that the *nat table's rule lines in
// the managed chains are exactly natLines, leaving the chain-policy lines, any // the managed chains are exactly natLines, leaving the chain-policy lines, any
// user-defined nat chain, the *filter table and all other content untouched. A // user-defined nat chain, unmodeled managed-chain lines, the *filter table and
// file with no *nat table gains one. It is the nat counterpart of // all other content untouched. A file with no *nat table gains one. It is the
// nat counterpart of rewriteFilterRules.
func (f *IPTables) rewriteNATRules(path string, natLines []string) error { func (f *IPTables) rewriteNATRules(path string, natLines []string) error {
lines, err := f.readAllLines(path) lines, err := f.readAllLines(path)
if err != nil { if err != nil {
return err return err
} }
preserved := f.preservedNATLines(lines)
out := make([]string, 0, len(lines)+len(natLines)) out := make([]string, 0, len(lines)+len(natLines))
inNat := false inNat := false
inserted := false inserted := false
for _, line := range lines { for idx, line := range lines {
t := strings.TrimSpace(line) t := strings.TrimSpace(line)
switch { switch {
case strings.HasPrefix(t, "*"): case strings.HasPrefix(t, "*"):
@ -2876,9 +3027,10 @@ func (f *IPTables) rewriteNATRules(path string, natLines []string) error {
inNat = false inNat = false
case inNat && strings.HasPrefix(f.ruleLineBody(t), "-A "): case inNat && strings.HasPrefix(f.ruleLineBody(t), "-A "):
// Drop an existing rule in a managed chain — the desired set replaces it — // Drop an existing rule in a managed chain — the desired set replaces it —
// but preserve a rule in a user-defined nat chain verbatim, mirroring how // but preserve a rule in a user-defined nat chain, and an unmodeled
// rewriteFilterRules preserves FORWARD/custom-chain rules. // managed-chain line, verbatim, mirroring how rewriteFilterRules
if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) { // preserves FORWARD/custom-chain rules and unmodeled filter lines.
if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) || preserved[idx] {
out = append(out, line) out = append(out, line)
} }
default: default:
@ -3154,7 +3306,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
return nil, err return nil, err
} }
// Scan each line to find where we should insert our rule. // Scan each line to find the insertion point.
scanner := bufio.NewScanner(fd) scanner := bufio.NewScanner(fd)
writtenRule := false writtenRule := false
filterFound := false filterFound := false
@ -3165,7 +3317,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
writtenRule = true writtenRule = true
} }
for scanner.Scan() { for scanner.Scan() {
// Trim line and check if we skip the line. // Trim the line and check whether it is skipped.
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
// Look for the filter, and skip if not reached. // Look for the filter, and skip if not reached.
@ -3202,7 +3354,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
// A rule that was never written means the filter table was malformed. // A rule that was never written means the filter table was malformed.
if !writtenRule { if !writtenRule {
af.Abort() af.Abort()
return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file") return nil, fmt.Errorf("failed to write the new rule to the iptables-save file")
} }
return af, nil return af, nil

View file

@ -818,7 +818,9 @@ func TestIPTablesMoveForeignChainPosition(t *testing.T) {
// TestIPTablesLogicalStarts checks that logicalStarts numbers physical `-A` // TestIPTablesLogicalStarts checks that logicalStarts numbers physical `-A`
// lines the same way GetRules (coalesceLoggedRules) does: a LOG line paired with // lines the same way GetRules (coalesceLoggedRules) does: a LOG line paired with
// its action is one logical rule beginning at the LOG line, an orphan LOG line // its action is one logical rule beginning at the LOG line, an orphan LOG line
// begins none, and a foreign line the parser rejects (nil) is an ordinary rule. // begins none, and a foreign line the parser rejects (nil) begins none either:
// GetRules never reports it, and Rule.Number promises to mirror the position
// argument of InsertRule/MoveRule, so the two numberings must not diverge.
func TestIPTablesLogicalStarts(t *testing.T) { func TestIPTablesLogicalStarts(t *testing.T) {
f := new(IPTables) f := new(IPTables)
logRule := func(proto Protocol, port uint16) *Rule { logRule := func(proto Protocol, port uint16) *Rule {
@ -837,7 +839,9 @@ func TestIPTablesLogicalStarts(t *testing.T) {
{"single orphan log dropped", []*Rule{act(TCP, 22), logRule(UDP, 53), act(TCP, 80)}, []int{1, 0, 2}}, {"single orphan log dropped", []*Rule{act(TCP, 22), logRule(UDP, 53), act(TCP, 80)}, []int{1, 0, 2}},
{"consecutive orphan logs dropped", []*Rule{logRule(UDP, 53), logRule(UDP, 123), act(TCP, 22), act(TCP, 80)}, []int{0, 0, 1, 2}}, {"consecutive orphan logs dropped", []*Rule{logRule(UDP, 53), logRule(UDP, 123), act(TCP, 22), act(TCP, 80)}, []int{0, 0, 1, 2}},
{"trailing orphan log", []*Rule{act(TCP, 22), logRule(UDP, 53)}, []int{1, 0}}, {"trailing orphan log", []*Rule{act(TCP, 22), logRule(UDP, 53)}, []int{1, 0}},
{"foreign nil line counts as a rule", []*Rule{nil, act(TCP, 22)}, []int{1, 2}}, {"foreign nil line begins no logical rule", []*Rule{nil, act(TCP, 22)}, []int{0, 1}},
{"foreign nil line between rules", []*Rule{act(TCP, 22), nil, act(TCP, 80)}, []int{1, 0, 2}},
{"foreign nil line between log and action breaks the pair", []*Rule{logRule(TCP, 22), nil, act(TCP, 22)}, []int{0, 0, 1}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@ -1916,3 +1920,122 @@ func TestIPTablesMarshalTCPUDPErrors(t *testing.T) {
_, err := fw.MarshalRule(&Rule{Proto: TCPUDP, Port: 80, Action: Accept}) _, err := fw.MarshalRule(&Rule{Proto: TCPUDP, Port: 80, Action: Accept})
require.Error(t, err, "MarshalRule must reject an unexpanded TCPUDP rule") require.Error(t, err, "MarshalRule must reject an unexpanded TCPUDP rule")
} }
// TestIPTablesNATParserRejectsUnmodeledMatches pins the shapes UnmarshalNATRule
// must leave foreign: a negated interface/protocol/port match (reading
// `! -o docker0` as a plain match would invert Docker's stock masquerade rule),
// an unknown protocol token, and a source-or-destination multiport option.
func TestIPTablesNATParserRejectsUnmodeledMatches(t *testing.T) {
fw := new(IPTables)
foreign := []string{
"-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE",
"-A PREROUTING ! -i eth0 -p tcp -m tcp --dport 80 -j REDIRECT --to-ports 8080",
"-A PREROUTING ! -p tcp -j REDIRECT --to-ports 8080",
"-A PREROUTING -p tcp -m tcp ! --dport 80 -j REDIRECT --to-ports 8080",
"-A PREROUTING -p udplite -j REDIRECT --to-ports 8080",
"-A PREROUTING -p 47 -j REDIRECT --to-ports 8080",
"-A PREROUTING -p udp -m multiport --ports 5060 -j REDIRECT --to-ports 5061",
}
for _, spec := range foreign {
_, err := fw.UnmarshalNATRule(spec, IPv4)
require.Error(t, err, "line must stay foreign: %s", spec)
}
// A negated address still models, and the plain forms still parse.
r, err := fw.UnmarshalNATRule("-A POSTROUTING ! -s 172.17.0.0/16 -o eth0 -j MASQUERADE", IPv4)
require.NoError(t, err)
require.Equal(t, "!172.17.0.0/16", r.Source)
require.Equal(t, "eth0", r.Interface)
r, err = fw.UnmarshalNATRule("-A PREROUTING -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.0.0.5:80", IPv4)
require.NoError(t, err)
require.Equal(t, DNAT, r.Kind)
require.EqualValues(t, 8080, r.Port)
}
// TestIPTablesRewriteNATPreservesUnmodeled pins that a Restore-style nat rewrite
// keeps managed-chain lines the model cannot hold — Docker's addrtype jump — while
// still replacing the modeled rules.
func TestIPTablesRewriteNATPreservesUnmodeled(t *testing.T) {
dir := t.TempDir()
save := "*nat\n:PREROUTING ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n" +
"-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER\n" +
"-A PREROUTING -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.0.0.5:80\n" +
"-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE\n" +
"COMMIT\n*filter\n:INPUT ACCEPT [0:0]\nCOMMIT\n"
p := filepath.Join(dir, "iptables")
require.NoError(t, os.WriteFile(p, []byte(save), 0644))
fw := &IPTables{IP4Path: p, IP6Path: filepath.Join(dir, "ip6tables")}
require.NoError(t, fw.rewriteNATRules(p, []string{"-A PREROUTING -p tcp -m tcp --dport 9090 -j DNAT --to-destination 10.0.0.6:90"}))
got, err := os.ReadFile(p)
require.NoError(t, err)
body := string(got)
require.Contains(t, body, "-j DOCKER", "Docker's unmodeled prerouting jump must survive the rewrite")
require.Contains(t, body, "! -o docker0", "Docker's negated masquerade must survive the rewrite")
require.Contains(t, body, "--dport 9090", "the desired rule must be written")
require.NotContains(t, body, "--dport 8080", "the replaced modeled rule must be gone")
}
// TestIPTablesLogPairSplitByForeignLine pins that a LOG line and an action line
// separated by an unmodeled foreign line are not coalesced into one logged rule:
// the pair reads apart everywhere (GetRules, positions, removal), so reporting a
// merged rule would surface one no removal could ever find.
func TestIPTablesLogPairSplitByForeignLine(t *testing.T) {
dir := t.TempDir()
save := "*filter\n:INPUT ACCEPT [0:0]\n" +
"-A INPUT -p tcp -m tcp --dport 80 -j LOG --log-prefix \"web: \"\n" +
"-A INPUT -m recent --name probe --set -j DROP\n" +
"-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" +
"COMMIT\n"
p := filepath.Join(dir, "iptables")
require.NoError(t, os.WriteFile(p, []byte(save), 0644))
fw := &IPTables{IP4Path: p, IP6Path: filepath.Join(dir, "ip6tables")}
rules, err := fw.parseFilterFile(p, IPv4)
require.NoError(t, err)
require.Len(t, rules, 1, "only the plain accept is a modeled rule")
require.False(t, rules[0].Log, "the accept must not inherit the separated LOG line")
// Removing the reported rule must find its line even with the split pair.
ctx := context.Background()
require.NoError(t, os.WriteFile(filepath.Join(dir, "ip6tables"), []byte("*filter\nCOMMIT\n"), 0644))
require.NoError(t, fw.RemoveRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept}))
got, err := os.ReadFile(p)
require.NoError(t, err)
require.NotContains(t, string(got), "-j ACCEPT", "the reported rule must be removable")
require.Contains(t, string(got), "-j LOG", "the orphaned LOG line is unmodeled and preserved")
require.Contains(t, string(got), "-m recent", "the foreign line is preserved")
}
// TestIPTablesInsertPositionSkipsForeignLine pins that Insert positions count
// only the rules GetRules reports: an unmodeled foreign line holds no position,
// so Rule.Number and the InsertRule position argument cannot diverge.
func TestIPTablesInsertPositionSkipsForeignLine(t *testing.T) {
dir := t.TempDir()
save := "*filter\n:INPUT ACCEPT [0:0]\n" +
"-A INPUT -m recent --name probe --set -j DROP\n" +
"-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" +
"-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" +
"COMMIT\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("*filter\nCOMMIT\n"), 0644))
fw := &IPTables{IP4Path: p4, IP6Path: p6}
ctx := context.Background()
// GetRules reports #1 dport 22, #2 dport 80. Insert at 2 = between them.
require.NoError(t, fw.InsertRule(ctx, "", 2, &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept}))
rules, err := fw.GetRules(ctx, "")
require.NoError(t, err)
byPort := map[uint16]int{}
for _, r := range rules {
byPort[r.Port] = r.Number
}
require.Equal(t, 1, byPort[22])
require.Equal(t, 2, byPort[443], "position 2 must land between the two modeled rules, not before dport 22")
require.Equal(t, 3, byPort[80])
got, err := os.ReadFile(p4)
require.NoError(t, err)
require.Contains(t, string(got), "-m recent", "the foreign line survives the insert")
}

View file

@ -2,50 +2,38 @@ package firewall
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
) )
// NewManager gets a firewall manager for this server. The context bounds the // NewManager gets a firewall manager for this server, probing the higher-level
// managers first: firewalld, then ufw/csf/apf, then plain iptables, and
// nftables last, so a manager that is itself backed by iptables or nftables is
// preferred over managing its tables behind its back. The context bounds the
// detection probes (each shells out or opens a D-Bus/systemd connection). // detection probes (each shells out or opens a D-Bus/systemd connection).
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) { func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
// First check for firewalld as there are no firewall managers that use it. // A probe error usually means "not installed", but it can also be a
firewalld, err := NewFirewallD(ctx, rulePrefix) // transient failure on a host whose firewall IS that manager (a D-Bus
// hiccup with firewalld running); every probe's reason is carried in the
// final error so a mis-detection is diagnosable.
var errs []error
probes := []struct {
name string
try func() (Manager, error)
}{
{"firewalld", func() (Manager, error) { return NewFirewallD(ctx, rulePrefix) }},
{"ufw", func() (Manager, error) { return NewUFW(ctx, rulePrefix) }},
{"csf", func() (Manager, error) { return NewCSF(ctx, rulePrefix) }},
{"apf", func() (Manager, error) { return NewAPF(ctx, rulePrefix) }},
{"iptables", func() (Manager, error) { return NewIPTables(ctx, rulePrefix) }},
{"nftables", func() (Manager, error) { return NewNFT(ctx, rulePrefix) }},
}
for _, p := range probes {
mgr, err := p.try()
if err == nil { if err == nil {
return firewalld, nil return mgr, nil
} }
errs = append(errs, fmt.Errorf("%s: %w", p.name, err))
// Check if Ubuntu's Uncomplicated Firewall (UFW) is installed.
ufw, err := NewUFW(ctx, rulePrefix)
if err == nil {
return ufw, nil
} }
return nil, fmt.Errorf("no firewall manager found: %w", errors.Join(errs...))
// Check if ConfigServer Security & Firewall is installed.
csf, err := NewCSF(ctx, rulePrefix)
if err == nil {
return csf, nil
}
// Check if Advanced Policy Firewall is installed.
apf, err := NewAPF(ctx, rulePrefix)
if err == nil {
return apf, nil
}
// After checking common firewall managers that use iptables, try iptables.
iptables, err := NewIPTables(ctx, rulePrefix)
if err == nil {
return iptables, nil
}
// As a last resort, manage nftables directly. This is tried last so that a
// higher-level manager (which may itself be backed by nftables) is preferred
// when one is present.
nft, err := NewNFT(ctx, rulePrefix)
if err == nil {
return nft, nil
}
// If no manager found via the known managers, fail.
return nil, fmt.Errorf("no firewall manager found")
} }

View file

@ -5,12 +5,12 @@ import (
"fmt" "fmt"
) )
// NewManager gets a firewall manager for this server; Windows has a single
// backend, the Windows Filtering Platform. The context bounds the probe.
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) { func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
// Attempt to connect to WFP.
wf, err := NewWF(ctx, rulePrefix) wf, err := NewWF(ctx, rulePrefix)
if err == nil { if err == nil {
return wf, nil return wf, nil
} }
return nil, fmt.Errorf("no firewall manager found: %w", err)
return nil, fmt.Errorf("no firewall manager found")
} }

File diff suppressed because it is too large Load diff

View file

@ -549,6 +549,9 @@ func TestNFTLogLimitRoundTrip(t *testing.T) {
// A non-default burst (nft's default is 5, which normalizes to the unset 0). // A non-default burst (nft's default is 5, which normalizes to the unset 0).
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 10}}, {Family: IPv4, Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 10}},
{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}}, {Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}},
// Per-source counting keys on the source address in a named meter set
// (TestNFTPerSourceConnLimit covers the listed-form parses).
{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}},
{Family: IPv4, Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept}, {Family: IPv4, Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept},
{Family: IPv4, Proto: TCP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, {Family: IPv4, Proto: TCP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
} }
@ -559,10 +562,6 @@ func TestNFTLogLimitRoundTrip(t *testing.T) {
require.NoError(t, err, "expr %q", expr) require.NoError(t, err, "expr %q", expr)
require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got) require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got)
} }
// Per-source connection limiting is not expressible in this model.
_, _, err := f.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}})
require.Error(t, err)
} }
func TestNFTNATRoundTrip(t *testing.T) { func TestNFTNATRoundTrip(t *testing.T) {
@ -740,3 +739,155 @@ func TestNFTNATRejectsMergedProtocol(t *testing.T) {
_, _, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCPUDP, Port: 80, ToAddress: "192.0.2.1"}) _, _, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCPUDP, Port: 80, ToAddress: "192.0.2.1"})
require.ErrorIs(t, err, ErrUnsupportedNAT) require.ErrorIs(t, err, ErrUnsupportedNAT)
} }
// A LogPrefix or comment with consecutive interior spaces must round-trip:
// LogPrefix is part of rule identity, so a collapsed read-back would never match
// its own rule and Sync would add a duplicate row each pass.
func TestNFTQuotedSpacingRoundTrip(t *testing.T) {
fw := &NFT{table: "go_firewall"}
orig := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop,
Log: true, LogPrefix: "FW DROP: ", Comment: "web block # one"}
chain, expr, err := fw.MarshalRule(orig)
require.NoError(t, err)
parsed, handle, err := fw.UnmarshalRule(expr+" # handle 9", chain)
require.NoError(t, err)
require.Equal(t, "9", handle)
require.Equal(t, "FW DROP: ", parsed.LogPrefix, "interior spacing must survive the round trip")
require.Equal(t, "web block # one", parsed.Comment)
require.True(t, parsed.Equal(orig, true), "want %+v got %+v", orig, parsed)
}
// An unrecognized single l4proto token is a foreign rule's protocol the model
// cannot hold; it must fail the parse (leaving the row opaque) rather than read
// back as a match-every-protocol rule.
func TestNFTUnknownL4ProtoRejected(t *testing.T) {
fw := &NFT{table: "go_firewall"}
_, _, err := fw.UnmarshalRule("meta l4proto igmp accept # handle 3", "input")
require.Error(t, err)
_, _, err = fw.UnmarshalRule("meta l4proto 253 accept # handle 4", "input")
require.Error(t, err)
// The one modeled set and the plain transports still parse.
r, _, err := fw.UnmarshalRule("meta l4proto { tcp, udp } th dport 53 accept # handle 5", "input")
require.NoError(t, err)
require.Equal(t, TCPUDP, r.Proto)
}
// sanitizeNFTName must never yield an identifier nftables rejects: identifiers
// begin with a letter.
func TestNFTSanitizeNameLeadingDigit(t *testing.T) {
require.Equal(t, "fw_9lives", sanitizeNFTName("9lives"))
require.Equal(t, "myapp", sanitizeNFTName("myapp"))
require.Equal(t, NFTDefaultTable, sanitizeNFTName("!!!"))
}
// lineHandle pulls the trailing handle marker off a rule line the model cannot
// parse, so the row still gets an opaque slot; a quoted comment containing the
// word "handle" cannot fool the end-anchored scan.
func TestNFTLineHandle(t *testing.T) {
fw := &NFT{table: "go_firewall"}
require.Equal(t, "12", fw.lineHandle(`meta mark 0x1 accept # handle 12`))
require.Equal(t, "7", fw.lineHandle(`tcp dport 22 accept comment "keep handle steady" # handle 7`))
require.Equal(t, "", fw.lineHandle("type filter hook input priority filter; policy accept;"))
}
// physicalIndex maps a 1-based modeled-row position to the physical index,
// keeping opaque rows pinned before the slot.
func TestNFTPhysicalIndex(t *testing.T) {
a, b := &Rule{Port: 1}, &Rule{Port: 2}
rows := []*Rule{nil, a, nil, b}
require.Equal(t, 1, physicalIndex(rows, 1))
require.Equal(t, 3, physicalIndex(rows, 2))
require.Equal(t, 4, physicalIndex(rows, 3), "past the last modeled row appends")
}
// A family-agnostic set-referencing rule cannot be rendered faithfully — a named
// set is family-typed — so the entry points pin it to the referenced set's own
// family (resolveSetRefFamily) before marshalling, and the marshaller guards
// against an unresolved one reaching it rather than silently pinning to IPv4.
func TestNFTSetRefRequiresConcreteFamily(t *testing.T) {
fw := &NFT{table: "go_firewall"}
_, _, err := fw.MarshalRule(&Rule{Source: "blocklist", Action: Drop})
require.Error(t, err, "an unresolved family-agnostic set reference must not marshal")
_, _, err = fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Action: Drop})
require.NoError(t, err)
// A NAT rule whose only family signal is the set reference is equally
// unrenderable; one with a concrete address is implied by it and passes.
_, _, err = fw.MarshalNATRule(&NATRule{Kind: Masquerade, Source: "natpool"})
require.Error(t, err)
_, _, err = fw.MarshalNATRule(&NATRule{Kind: SNAT, Source: "natpool", ToAddress: "192.0.2.1"})
require.NoError(t, err)
}
// A per-source connection limit marshals as a named meter keyed on the source
// address and reads back from nft's normalized `add @set { ... }` listing (and
// the written `meter`/`update` spellings). The meter key pins the family, so no
// nfproto match is emitted — nft would fold it away and the text would never
// round-trip.
func TestNFTPerSourceConnLimit(t *testing.T) {
fw := &NFT{table: "go_firewall"}
r := &Rule{Family: IPv4, Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}
chain, expr, err := fw.MarshalRule(r)
require.NoError(t, err)
require.Equal(t, "input", chain)
require.Contains(t, expr, "meter cl")
require.Contains(t, expr, "{ ip saddr ct count over 15 }")
require.NotContains(t, expr, "nfproto", "the meter key already pins the family; an nfproto match would not round-trip")
// The written form parses back to the same rule.
parsed, _, err := fw.UnmarshalRule(expr, chain)
require.NoError(t, err)
require.True(t, parsed.Equal(r, true), "written form mismatch: %+v vs %+v", parsed, r)
// nft lists the meter as an `add @set` statement; the parse captures the
// count, the per-source flag, the family from the key, and the set name for
// removal-time cleanup.
listed := "tcp dport 8081 add @cl4a5b { ip saddr ct count over 15 } counter packets 0 bytes 0 reject with icmp port-unreachable # handle 7"
parsed, handle, err := fw.UnmarshalRule(listed, "input")
require.NoError(t, err)
require.Equal(t, "7", handle)
require.True(t, parsed.Equal(r, true), "listed form mismatch: %+v vs %+v", parsed, r)
require.Equal(t, "cl4a5b", parsed.meterSet, "the set name must be captured for removal-time cleanup")
// The IPv6 key reads back as an IPv6 rule.
listed6 := "tcp dport 8081 add @cl6f { ip6 saddr ct count over 15 } counter packets 0 bytes 0 reject # handle 9"
parsed6, _, err := fw.UnmarshalRule(listed6, "input")
require.NoError(t, err)
require.Equal(t, IPv6, parsed6.Family, "the meter key must pin the family")
require.True(t, parsed6.ConnLimit != nil && parsed6.ConnLimit.PerSource && parsed6.ConnLimit.Count == 15)
// The `update` spelling (a timeout-refreshing meter) parses the same way.
updated := "tcp dport 8081 update @cl4a5b { ip saddr ct count over 15 } counter packets 0 bytes 0 drop # handle 11"
parsedU, _, err := fw.UnmarshalRule(updated, "input")
require.NoError(t, err)
require.True(t, parsedU.ConnLimit != nil && parsedU.ConnLimit.PerSource)
// nft 1.0.x echoes the written meter spelling back, with a size qualifier.
legacy := "tcp dport 8081 meter cl4a5b size 65535 { ip saddr ct count over 15 } counter packets 0 bytes 0 reject with icmp port-unreachable # handle 13"
parsedL, _, err := fw.UnmarshalRule(legacy, "input")
require.NoError(t, err)
require.True(t, parsedL.Equal(r, true), "nft 1.0.x meter listing mismatch: %+v vs %+v", parsedL, r)
require.Equal(t, "cl4a5b", parsedL.meterSet)
// An unmodeled dynamic-set body fails the parse (the row stays opaque)
// rather than reading back as a wrong rule.
_, _, err = fw.UnmarshalRule("tcp dport 80 add @tracker { ip saddr limit rate 10/second } counter drop # handle 12", "input")
require.Error(t, err, "a non-connlimit dynamic-set body is not modeled")
// A FamilyAny per-source rule has no single row; the entry points fan it out
// before marshalling, so the marshaller itself refuses it.
_, _, err = fw.MarshalRule(&Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
require.Error(t, err)
// The meter name is deterministic for one rule and distinct across rules.
r2 := &Rule{Family: IPv4, Proto: TCP, Port: 8082, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}
require.Equal(t, fw.meterName("input", r), fw.meterName("input", r), "same rule, same set")
require.NotEqual(t, fw.meterName("input", r), fw.meterName("input", r2), "distinct rules must not share counting state")
// A global (non-per-source) limit keeps the plain ct count form.
_, exprGlobal, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}})
require.NoError(t, err)
require.Contains(t, exprGlobal, "ct count over 20")
require.NotContains(t, exprGlobal, "meter")
}

121
pf.go
View file

@ -71,6 +71,7 @@ func (f *PF) Capabilities() Capabilities {
// a single rule (a range, kept as one token, does). Report PortList as // a single rule (a range, kept as one token, does). Report PortList as
// unsupported to reflect that; callers open several ports with a rule each. // unsupported to reflect that; callers open several ports with a rule each.
PortList: false, PortList: false,
PortPair: true,
ConnState: false, ConnState: false,
InterfaceMatch: true, InterfaceMatch: true,
Logging: true, Logging: true,
@ -82,6 +83,9 @@ func (f *PF) Capabilities() Capabilities {
RuleCounters: true, RuleCounters: true,
AddressSets: true, AddressSets: true,
Comments: true, Comments: true,
Negation: true,
RejectAction: true,
FamilyWithoutAddress: true,
} }
} }
@ -556,9 +560,11 @@ func (f *PF) parseAnchorRules(out []string) (rules []*Rule, raw []string) {
} }
} }
// A continuation line annotates the rule just parsed rather than starting a // A continuation line annotates the rule just parsed rather than starting a
// new one; pull its Packets/Bytes into that rule when present. // new one; pull its Packets/Bytes into that rule when present. The verbose
// listing prints counters under every rule, including an unmodeled one held
// as an opaque nil row, whose counters have nowhere to go.
if strings.HasPrefix(line, "[") { if strings.HasPrefix(line, "[") {
if len(rules) > 0 { if len(rules) > 0 && rules[len(rules)-1] != nil {
if p, b, ok := f.parseRuleCounters(line); ok { if p, b, ok := f.parseRuleCounters(line); ok {
rules[len(rules)-1].Packets = p rules[len(rules)-1].Packets = p
rules[len(rules)-1].Bytes = b rules[len(rules)-1].Bytes = b
@ -977,6 +983,10 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
} }
i++ i++
// srcScope is true between `from` and `to`, where a `port` token is a
// source-port match this model cannot hold; erroring keeps the line opaque
// rather than mis-reading it as a destination-port rule.
srcScope := false
for ; i < len(tokens); i++ { for ; i < len(tokens); i++ {
switch tokens[i] { switch tokens[i] {
case "pass", "quick", "log": case "pass", "quick", "log":
@ -1005,6 +1015,7 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
return nil, fmt.Errorf("unsupported protocol: %s", tokens[i]) return nil, fmt.Errorf("unsupported protocol: %s", tokens[i])
} }
case "from": case "from":
srcScope = true
val, neg, next, err := f.parseAddr(tokens, i+1) val, neg, next, err := f.parseAddr(tokens, i+1)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1014,6 +1025,7 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
r.Source = neg + f.stripTable(val) r.Source = neg + f.stripTable(val)
} }
case "to": case "to":
srcScope = false
val, neg, next, err := f.parseAddr(tokens, i+1) val, neg, next, err := f.parseAddr(tokens, i+1)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1023,6 +1035,9 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
r.Destination = neg + f.stripTable(val) r.Destination = neg + f.stripTable(val)
} }
case "port": case "port":
if srcScope {
return nil, fmt.Errorf("source port match is not modeled")
}
i++ i++
if i >= len(tokens) { if i >= len(tokens) {
return nil, fmt.Errorf("missing port value") return nil, fmt.Errorf("missing port value")
@ -1050,8 +1065,13 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
} }
target := tokens[i] target := tokens[i]
if strings.HasPrefix(target, "(") { if strings.HasPrefix(target, "(") {
// A dynamic interface address is masquerade. // A dynamic interface address is masquerade. A port pool after it
// (`port 1024:65535`) has no single ToPort to map onto, so the line
// stays opaque.
r.Kind = Masquerade r.Kind = Masquerade
if i+1 < len(tokens) && tokens[i+1] == "port" {
return nil, fmt.Errorf("nat target port pool is not modeled")
}
} else { } else {
r.ToAddress = target r.ToAddress = target
// An optional `port N` gives the translation port. pfctl prints a // An optional `port N` gives the translation port. pfctl prints a
@ -1223,6 +1243,27 @@ var pfFilterKeywords = map[string]bool{
"antispoof": true, "antispoof": true,
} }
// marshalExpanded renders rules as anchor filter lines, fanning a DirAny rule
// into an inbound rule plus its swapped outbound rule and a TCPUDP rule into a
// tcp rule and a udp rule; pf stores each direction/transport as its own row.
// Restore and ReplaceRulesBatch share it so a merged rule from a portable
// backup expands exactly as the add paths expand it.
func (f *PF) marshalExpanded(rules []*Rule) ([]string, error) {
var lines []string
for _, top := range rules {
for _, dir := range expandDirections(top) {
for _, r := range expandProtocols(dir) {
line, err := f.MarshalRule(r)
if err != nil {
return nil, err
}
lines = append(lines, line)
}
}
}
return lines, nil
}
// loadAnchor replaces the anchor's ruleset with the provided rules. pf requires // loadAnchor replaces the anchor's ruleset with the provided rules. pf requires
// nat/rdr (translation) rules to precede filter rules in the ruleset, so they // nat/rdr (translation) rules to precede filter rules in the ruleset, so they
// are written first. An empty combined set flushes the anchor. // are written first. An empty combined set flushes the anchor.
@ -1442,10 +1483,10 @@ func (f *PF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return err return err
} }
// Rebuild the filter ruleset without the matching rule(s). GetRules collapses an // Rebuild the filter ruleset without the matching rule(s). A FamilyAny target
// IPv4/IPv6 twin into one FamilyAny rule, so removing that read-back rule must // must clear every row it covers — an unpinned dual-family row and any
// clear both underlying anchor rows; match with EqualForRemoval so a concrete- // family-pinned twins added one at a time — so match with EqualForRemoval; a
// family target still removes only its own family and never the twin's row. // concrete-family target still removes only its own family, never the twin's row.
kept := make([]string, 0, len(filterRaw)) kept := make([]string, 0, len(filterRaw))
removed := false removed := false
var reAdd *Rule var reAdd *Rule
@ -1656,11 +1697,10 @@ func (f *PF) translationBoundary(data []string) int {
return len(data) return len(data)
} }
// ensureNATAnchors inserts them at that boundary. pf.conf sections are strictly // ensureNATAnchors makes sure pf.conf carries the nat-anchor and rdr-anchor
// ordered options → normalization → queueing → translation → filtering, so the // references for our anchor, inserting any missing ones at the translation
// queueing keywords (altq/queue) are deliberately NOT included: they precede the // boundary (see translationBoundary), and ensures the filter anchor first via
// translation section, and treating one as the boundary would splice the nat/rdr // ensureAnchor.
// anchors ahead of the queueing statements, which pfctl -f rejects.
func (f *PF) ensureNATAnchors(ctx context.Context) error { func (f *PF) ensureNATAnchors(ctx context.Context) error {
if f.natEnsured { if f.natEnsured {
return nil return nil
@ -1861,13 +1901,24 @@ func (f *PF) isMissingTableErr(err error) bool {
return err != nil && strings.Contains(err.Error(), "does not exist") return err != nil && strings.Contains(err.Error(), "does not exist")
} }
// tableFamily infers a table's family from its entries (defaulting to IPv4). // tableFamily infers a table's family from its entries: IPv6 when every entry
// is IPv6, FamilyAny when the table mixes families (a pf table legitimately
// holds both), and IPv4 otherwise.
func (f *PF) tableFamily(entries []string) Family { func (f *PF) tableFamily(entries []string) Family {
v4, v6 := false, false
for _, e := range entries { for _, e := range entries {
if strings.Contains(e, ":") { if strings.Contains(e, ":") {
return IPv6 v6 = true
} else {
v4 = true
} }
} }
switch {
case v4 && v6:
return FamilyAny
case v6:
return IPv6
}
return IPv4 return IPv4
} }
@ -1896,7 +1947,10 @@ func (f *PF) getAddressSet(ctx context.Context, name string) (*AddressSet, error
func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
out, err := runCommand(ctx, "pfctl", "-s", "Tables") out, err := runCommand(ctx, "pfctl", "-s", "Tables")
if err != nil { if err != nil {
return nil, nil // A failed listing must surface: reporting "no sets" would let a Backup
// silently capture zero tables and a later Restore load rules whose
// <table> references resolve to nothing.
return nil, err
} }
var result []*AddressSet var result []*AddressSet
for _, line := range out { for _, line := range out {
@ -1905,7 +1959,10 @@ func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
continue continue
} }
set, err := f.getAddressSet(ctx, name) set, err := f.getAddressSet(ctx, name)
if err != nil || set == nil { if err != nil {
return nil, err
}
if set == nil {
continue continue
} }
result = append(result, set) result = append(result, set)
@ -1933,14 +1990,11 @@ func (f *PF) AddAddressSet(ctx context.Context, set *AddressSet) error {
if err := f.ensureAnchor(ctx); err != nil { if err := f.ensureAnchor(ctx); err != nil {
return err return err
} }
if len(set.Entries) == 0 { // `-T replace` reconciles an existing table to exactly the given entries
// (where `-T add` would only union), and with none creates the table empty —
// pf does not lazily create tables, so a later filter rule referencing // pf does not lazily create tables, so a later filter rule referencing
// <name> would fail to load. `-T replace` with no addresses creates the // <name> would otherwise fail to load.
// table empty (or empties an existing one) so it exists for the caller. args := []string{"-t", set.Name, "-T", "replace"}
_, err := runCommand(ctx, "pfctl", "-t", set.Name, "-T", "replace")
return err
}
args := []string{"-t", set.Name, "-T", "add"}
args = append(args, set.Entries...) args = append(args, set.Entries...)
_, err := runCommand(ctx, "pfctl", args...) _, err := runCommand(ctx, "pfctl", args...)
return err return err
@ -2031,14 +2085,12 @@ func (f *PF) Restore(ctx context.Context, zoneName string, backup *Backup) error
return err return err
} }
filterLines := make([]string, 0, len(backup.Rules)) // A portable backup may carry DirAny or TCPUDP rules captured from a backend
for _, r := range backup.Rules { // that stores them as one row; expand exactly as the add paths do.
line, err := f.MarshalRule(r) filterLines, err := f.marshalExpanded(backup.Rules)
if err != nil { if err != nil {
return err return err
} }
filterLines = append(filterLines, line)
}
natLines := make([]string, 0, len(backup.NATRules)) natLines := make([]string, 0, len(backup.NATRules))
for _, r := range backup.NATRules { for _, r := range backup.NATRules {
@ -2120,20 +2172,9 @@ func (f *PF) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Ru
return err return err
} }
var filterRaw []string filterRaw, err := f.marshalExpanded(rules)
for _, top := range rules {
// A DirAny rule fans out into an inbound rule plus its swapped outbound rule,
// and a TCPUDP rule into a tcp rule and a udp rule; pfctl stores each
// direction/transport as its own row.
for _, dir := range expandDirections(top) {
for _, r := range expandProtocols(dir) {
line, err := f.MarshalRule(r)
if err != nil { if err != nil {
return err return err
} }
filterRaw = append(filterRaw, line)
}
}
}
return f.loadAnchor(ctx, natRaw, filterRaw) return f.loadAnchor(ctx, natRaw, filterRaw)
} }

View file

@ -707,3 +707,89 @@ func TestParsePFAddrDoesNotMutateTokens(t *testing.T) {
require.Equal(t, 1, next) require.Equal(t, 1, next)
require.Equal(t, "!", tokens[0]) require.Equal(t, "!", tokens[0])
} }
// A verbose listing prints a counters continuation under every rule, including an
// unmodeled one held as an opaque nil row; attaching those counters must not
// dereference the nil slot.
func TestPFVerboseCountersAfterUnmodeledRow(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
out := []string{
"pass in quick inet proto tcp from any to any port > 1023 keep state",
" [ Evaluations: 100 Packets: 40 Bytes: 2400 States: 2 ]",
"pass in quick inet proto udp from any to any port = 53 keep state",
" [ Evaluations: 5 Packets: 5 Bytes: 300 States: 0 ]",
}
rules, raw := fw.parseAnchorRules(out)
require.Len(t, rules, 2)
require.Len(t, raw, 2)
require.Nil(t, rules[0], "the unmodeled line stays an opaque row")
require.NotNil(t, rules[1])
require.EqualValues(t, 5, rules[1].Packets, "counters still attach to the modeled rule")
}
// A portable backup may carry DirAny or TCPUDP rules captured from a backend that
// stores them as one row; marshalExpanded (behind Restore and ReplaceRulesBatch)
// must fan them out exactly as the add paths do rather than fail or drop a half.
func TestPFMarshalExpandedMergedRules(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
merged := &Rule{Direction: DirAny, Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}
lines, err := fw.marshalExpanded([]*Rule{merged})
require.NoError(t, err)
require.Len(t, lines, 4, "DirAny x TCPUDP must expand to four rows")
var in, out, tcp, udp int
for _, l := range lines {
if strings.Contains(l, "pass in ") {
in++
}
if strings.Contains(l, "pass out ") {
out++
}
if strings.Contains(l, "proto tcp") {
tcp++
}
if strings.Contains(l, "proto udp") {
udp++
}
}
require.Equal(t, 2, in)
require.Equal(t, 2, out)
require.Equal(t, 2, tcp)
require.Equal(t, 2, udp)
// A concrete rule passes through unchanged.
lines, err = fw.marshalExpanded([]*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}})
require.NoError(t, err)
require.Len(t, lines, 1)
}
// A nat source-port match and a dynamic-target port pool have no faithful model;
// both must error so the line is preserved as an opaque row rather than mis-read.
func TestPFNATParserKeepsUnmodeledShapesOpaque(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
opaque := []string{
"nat on em0 inet from 10.0.0.0/8 port = 500 to any -> 192.0.2.1",
"nat on em0 inet from 10.0.0.0/8 to any -> (em0) port 1024:65535",
}
for _, line := range opaque {
_, err := fw.UnmarshalNATRule(line)
require.Error(t, err, "line must stay opaque: %s", line)
}
// The plain forms still parse.
r, err := fw.UnmarshalNATRule("nat on em0 inet from 10.0.0.0/8 to any -> (em0)")
require.NoError(t, err)
require.Equal(t, Masquerade, r.Kind)
r, err = fw.UnmarshalNATRule("rdr on em0 inet proto tcp from any to any port = 8080 -> 10.0.0.5 port 80")
require.NoError(t, err)
require.Equal(t, DNAT, r.Kind)
require.EqualValues(t, 8080, r.Port)
require.EqualValues(t, 80, r.ToPort)
}
// tableFamily types a table by its entries; a mixed table is FamilyAny, not IPv6.
func TestPFTableFamilyMixed(t *testing.T) {
fw := new(PF)
require.Equal(t, IPv4, fw.tableFamily([]string{"192.0.2.1", "198.51.100.0/24"}))
require.Equal(t, IPv6, fw.tableFamily([]string{"2001:db8::1"}))
require.Equal(t, FamilyAny, fw.tableFamily([]string{"192.0.2.1", "2001:db8::1"}))
require.Equal(t, IPv4, fw.tableFamily(nil))
}

View file

@ -10,7 +10,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
dbus "github.com/coreos/go-systemd/dbus" dbus "github.com/coreos/go-systemd/v22/dbus"
) )
// systemdActive reports whether systemd is the running init (/run/systemd/system // systemdActive reports whether systemd is the running init (/run/systemd/system
@ -27,9 +27,11 @@ func commandExists(name string) bool {
} }
// systemdUnitEnabled reports whether systemd reports name's unit as set to // systemdUnitEnabled reports whether systemd reports name's unit as set to
// start at boot. "enabled", "enabled-runtime", and "generated" all count; // start at boot. Only "enabled" and "enabled-runtime" count: a "generated"
// "generated" is how systemd wraps a SysV init.d script that was registered to // unit wraps a SysV init.d script and exists for every script regardless of
// start. Returns false when systemd is not running or the unit is absent. // its rc registration, so boot enablement for those is resolved through the
// SysV checks instead. Returns false when systemd is not running or the unit
// is absent.
func systemdUnitEnabled(ctx context.Context, name string) bool { func systemdUnitEnabled(ctx context.Context, name string) bool {
conn, err := dbus.NewWithContext(ctx) conn, err := dbus.NewWithContext(ctx)
if err != nil { if err != nil {
@ -41,7 +43,7 @@ func systemdUnitEnabled(ctx context.Context, name string) bool {
return false return false
} }
switch prop.Value.Value() { switch prop.Value.Value() {
case "enabled", "enabled-runtime", "generated": case "enabled", "enabled-runtime":
return true return true
} }
return false return false
@ -120,7 +122,9 @@ func openrcOn(name string) bool {
// rcLocalOn reports whether name is invoked from an rc.local file, apf's // rcLocalOn reports whether name is invoked from an rc.local file, apf's
// last-resort enablement when neither systemd nor an init.d registration // last-resort enablement when neither systemd nor an init.d registration
// applies. Commented lines are ignored. // applies. Commented lines are ignored, and name must appear as its own token
// (bare or path-qualified) so an unrelated mention such as a log-file path
// does not count.
func rcLocalOn(name string) bool { func rcLocalOn(name string) bool {
for _, p := range []string{"/etc/rc.local", "/etc/rc.d/rc.local"} { for _, p := range []string{"/etc/rc.local", "/etc/rc.d/rc.local"} {
data, err := os.ReadFile(p) data, err := os.ReadFile(p)
@ -132,11 +136,13 @@ func rcLocalOn(name string) bool {
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
continue continue
} }
if strings.Contains(line, name) { for _, tok := range strings.Fields(line) {
if tok == name || strings.HasSuffix(tok, "/"+name) {
return true return true
} }
} }
} }
}
return false return false
} }
@ -200,10 +206,17 @@ func enableService(ctx context.Context, name string) error {
return nil return nil
} }
switch state { switch state {
case "enabled", "enabled-runtime", "static", "generated": case "enabled", "enabled-runtime", "static":
// static units have no install info and are pulled in by other // Static units have no install info and are pulled in by other
// units, so there is nothing to enable. // units, so there is nothing to enable.
return nil return nil
case "generated":
// A generated unit wraps a SysV init.d script whose enablement
// lives in its rc registration, not the unit file. `systemctl
// enable` reaches that registration through systemd-sysv-install.
if sysvServiceEnabled(ctx, name) {
return nil
}
} }
if _, err := runCommand(ctx, "systemctl", "daemon-reload"); err != nil { if _, err := runCommand(ctx, "systemctl", "daemon-reload"); err != nil {
return fmt.Errorf("failed to reload systemd for %s: %s", name, err) return fmt.Errorf("failed to reload systemd for %s: %s", name, err)
@ -244,6 +257,15 @@ func enableService(ctx context.Context, name string) error {
func restartService(ctx context.Context, name string) error { func restartService(ctx context.Context, name string) error {
if systemdActive() { if systemdActive() {
if _, err := runCommand(ctx, "systemctl", "restart", name+".service"); err != nil { if _, err := runCommand(ctx, "systemctl", "restart", name+".service"); err != nil {
// A burst of restarts (a caller reconciling several times in quick
// succession) trips systemd's start rate limit and the unit lands in
// a failed start-limit-hit state; clear it and retry once rather
// than fail a reload the unit itself is healthy for.
if _, rerr := runCommand(ctx, "systemctl", "reset-failed", name+".service"); rerr == nil {
if _, err2 := runCommand(ctx, "systemctl", "restart", name+".service"); err2 == nil {
return nil
}
}
return fmt.Errorf("failed to restart %s: %s", name, err) return fmt.Errorf("failed to restart %s: %s", name, err)
} }
return nil return nil

View file

@ -21,11 +21,12 @@ const (
// UFWDefaults is ufw's defaults file, where the default-policy keys // UFWDefaults is ufw's defaults file, where the default-policy keys
// (DEFAULT_INPUT_POLICY, ...) actually live — not ufw.conf. // (DEFAULT_INPUT_POLICY, ...) actually live — not ufw.conf.
UFWDefaults = "/etc/default/ufw" UFWDefaults = "/etc/default/ufw"
// The iptables rules files hold rules that run before/after the user rules, // UFWBefore and its peers are the iptables rules files holding rules that run
// in iptables-restore format with ufw's own chains. They are the raw-iptables // before/after the user rules, in iptables-restore format with ufw's own
// fallback for what ufw's tuple format cannot express — ICMP, SCTP, state // chains. They are the raw-iptables fallback for what ufw's tuple format
// matches, custom log prefixes, rate/connection limits, and NAT (written into // cannot express — ICMP, SCTP, state matches, custom log prefixes,
// before.rules' nat table via natHelper). // rate/connection limits, and NAT (written into before.rules' nat table via
// natHelper).
UFWBefore = "/etc/ufw/before.rules" UFWBefore = "/etc/ufw/before.rules"
UFWBefore6 = "/etc/ufw/before6.rules" UFWBefore6 = "/etc/ufw/before6.rules"
UFWAfter = "/etc/ufw/after.rules" UFWAfter = "/etc/ufw/after.rules"
@ -56,7 +57,7 @@ func NewUFW(ctx context.Context, rulePrefix string) (*UFW, error) {
// Try and read the ufw config file. // Try and read the ufw config file.
fd, err := os.Open(UFWConf) fd, err := os.Open(UFWConf)
if err != nil { if err != nil {
return nil, fmt.Errorf("ufw config is not readable") return nil, fmt.Errorf("ufw config is not readable: %w", err)
} }
// Scan file for the enabled state. // Scan file for the enabled state.
@ -130,6 +131,7 @@ func (f *UFW) Capabilities() Capabilities {
Forward: true, Forward: true,
ICMPv6: true, ICMPv6: true,
PortList: true, PortList: true,
PortPair: true,
ConnState: true, ConnState: true,
InterfaceMatch: true, InterfaceMatch: true,
Logging: true, Logging: true,
@ -141,6 +143,9 @@ func (f *UFW) Capabilities() Capabilities {
RuleCounters: false, RuleCounters: false,
AddressSets: true, AddressSets: true,
Comments: true, Comments: true,
Negation: true,
RejectAction: true,
FamilyWithoutAddress: true,
} }
} }
@ -252,8 +257,9 @@ func (f *UFW) parseAddr(tok string) (string, error) {
// and sapp are just the profile's name, informational labels this library has no // and sapp are just the profile's name, informational labels this library has no
// field for, so they are parsed to locate the trailing direction field and then // field for, so they are parsed to locate the trailing direction field and then
// discarded; the rule decodes exactly like an ordinary 7-token tuple otherwise. // discarded; the rule decodes exactly like an ordinary 7-token tuple otherwise.
// An 8-token tuple never occurs in a real ufw file (ufw always writes both dapp // An 8-token tuple is ufw's old storage format (six core fields plus dapp/sapp
// and sapp, using "-" for whichever is absent) and is rejected as malformed. // with no direction field), which this library does not model; it is rejected so
// the row stays opaque and is preserved verbatim.
func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) { func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
r = &Rule{ r = &Rule{
Family: family, Family: family,
@ -271,9 +277,10 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
} }
n := len(tokens) n := len(tokens)
// A route rule binding both interfaces adds a second trailing interface token, // A dual-interface route rule stays at seven tokens — ufw joins both
// so it alone may reach eight tokens; every other tuple with eight is malformed. // interfaces into one trailing "!"-separated token (`in_eth0!out_eth1`) — so
if n < 6 || n > 9 || (n == 8 && !forward) { // eight tokens only occurs in ufw's unmodeled old format.
if n < 6 || n > 9 || n == 8 {
return nil, fmt.Errorf("invalid rule length") return nil, fmt.Errorf("invalid rule length")
} }
@ -309,25 +316,30 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
return nil, fmt.Errorf("unsupported action: %s", tokens[0]) return nil, fmt.Errorf("unsupported action: %s", tokens[0])
} }
// The trailing token(s) after the six core fields carry the direction and any // The trailing token after the six core fields carries the direction and any
// interface binding. An ordinary rule has one such token (`in`, `out`, or an // interface binding: `in`, `out`, an interface-bound `in_eth0`/`out_eth0`, or
// interface-bound `in_eth0`/`out_eth0`); a route rule binding both interfaces // — on a route rule binding both interfaces — ufw's "!"-joined pair
// has two (`in_eth0 out_eth1`). An application-profile tuple (n==9) carries the // `in_eth0!out_eth1`, still one token. An application-profile tuple (n==9)
// dapp/sapp labels in tokens 6-7 and the direction in token 8. A 6-token tuple // carries the dapp/sapp labels in tokens 6-7 and the direction in token 8. A
// omits the field and defaults to inbound. For a route rule the direction stays // 6-token tuple omits the field and defaults to inbound. For a route rule the
// forward and the interfaces populate InInterface/OutInterface; for an ordinary // direction stays forward and the interfaces populate InInterface/
// rule the single token fixes the in/out direction and its interface. // OutInterface; for an ordinary rule the token fixes the in/out direction and
var dirToks []string // its interface.
var dirTok string
switch n { switch n {
case 7: case 7:
dirToks = tokens[6:7] dirTok = tokens[6]
case 8:
dirToks = tokens[6:8]
case 9: case 9:
dirToks = tokens[8:9] dirTok = tokens[8]
} }
for _, tok := range dirToks { if dirTok != "" {
name, iface, hasIface := strings.Cut(tok, "_") parts := strings.SplitN(dirTok, "!", 2)
if len(parts) == 2 && !forward {
// Only a route rule carries both interfaces in one token.
return nil, fmt.Errorf("unsupported direction: %s", dirTok)
}
for _, part := range parts {
name, iface, hasIface := strings.Cut(part, "_")
switch name { switch name {
case "in": case "in":
if !forward { if !forward {
@ -344,7 +356,8 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
r.OutInterface = iface r.OutInterface = iface
} }
default: default:
return nil, fmt.Errorf("unsupported direction: %s", tok) return nil, fmt.Errorf("unsupported direction: %s", dirTok)
}
} }
} }
@ -449,8 +462,9 @@ func (f *UFW) parseTupleRows(filePath string, family Family) ([]*Rule, error) {
continue continue
} }
// Parse rule. A tuple this backend cannot model (e.g. a route/forward rule) // Parse rule. A tuple this backend cannot model (ufw's old 8-field
// is kept as a nil row so it still occupies a physical position. // format, an application profile it cannot resolve, a malformed line) is
// kept as a nil row so it still occupies a physical position.
rule, err := f.UnmarshalRule(line, family) rule, err := f.UnmarshalRule(line, family)
if err != nil { if err != nil {
rows = append(rows, nil) rows = append(rows, nil)
@ -653,8 +667,8 @@ func (f *UFW) MarshalRule(r *Rule) (string, error) {
return "", fmt.Errorf("an output interface cannot be matched on an input rule") return "", fmt.Errorf("an output interface cannot be matched on an input rule")
} }
// Work on a copy as we infer the family and normalize the destination // Work on a copy: the family is inferred and the destination normalized
// below, and we do not want to mutate the caller's rule. // below, and the caller's rule must not be mutated.
ruleCopy := *r ruleCopy := *r
r = &ruleCopy r = &ruleCopy
@ -979,15 +993,25 @@ func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove
if err != nil { if err != nil {
return false, err return false, err
} }
// Anchor on the *filter section's COMMIT, not the file's first one: the
// canonical ufw NAT setup adds a *nat block above *filter, and a filter rule
// spliced into that block references an undeclared chain, failing the whole
// reload at iptables-restore.
commitIdx := -1 commitIdx := -1
inFilter := false
for i, line := range lines { for i, line := range lines {
if strings.TrimSpace(line) == "COMMIT" { t := strings.TrimSpace(line)
if strings.HasPrefix(t, "*") {
inFilter = t == "*filter"
continue
}
if inFilter && t == "COMMIT" {
commitIdx = i commitIdx = i
break break
} }
} }
if commitIdx == -1 { if commitIdx == -1 {
return false, fmt.Errorf("no COMMIT line found in %s", path) return false, fmt.Errorf("no *filter COMMIT line found in %s", path)
} }
out := make([]string, 0, len(lines)+len(specs)) out := make([]string, 0, len(lines)+len(specs))
out = append(out, lines[:commitIdx]...) out = append(out, lines[:commitIdx]...)
@ -1141,9 +1165,10 @@ func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error {
return err return err
} }
args := f.ruleArgs(r, []string{"prepend"}, rule) args := f.ruleArgs(r, []string{"prepend"}, rule)
// Attach a comment: a user-supplied Comment takes precedence, otherwise the // Attach a comment: the prefix tag and any user-supplied Comment are combined
// rule is tagged with our prefix so it can be identified as ours. The comment // (prefix first, see combineComment) so the rule is identifiable as ours and
// is only added on insert; ufw matches deletes on the rule without it. // the user text still surfaces. The comment is only added on insert; ufw
// matches deletes on the rule without it.
if c := f.commentFor(r); c != "" { if c := f.commentFor(r); c != "" {
args = append(args, "comment", c) args = append(args, "comment", c)
} }
@ -1172,11 +1197,11 @@ func (f *UFW) appendRule(ctx context.Context, r *Rule) error {
// nativeInsertPositionFromRows maps a 1-based logical 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
// route/forward rule); it still occupies a physical slot, so a route rule preceding // (an old-format or otherwise unparseable tuple); it still occupies a physical
// the anchor shifts the native position instead of being ignored — which would // slot, so such a row preceding the anchor shifts the native position instead of
// place the rule one slot too early per preceding route rule and, for a first-match // being ignored — which would place the rule one slot too early per preceding
// firewall, change enforcement. Every modeled tuple is its own rule, so with no // unmodeled row and, for a first-match firewall, change enforcement. Every modeled tuple is its own rule, so with no
// un-representable rows this reduces to the plain physical position. // 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 physPos []int var physPos []int
@ -1198,8 +1223,8 @@ func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int {
// nativeInsertPosition maps a 1-based logical position (a rule's Number, as GetRules // 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`. // 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 // The two index spaces differ because ufw's list also counts tuples this backend
// model. The tuple order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's // 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 // 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 // position past the last logical rule maps past the native count, which ufw rejects
// and InsertRule then handles as a plain append. // and InsertRule then handles as a plain append.
@ -1257,9 +1282,9 @@ func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r *
} }
// position is a Number GetRules reported, which counts only the tuples this // position is a Number GetRules reported, which counts only the tuples this
// backend models, while `ufw insert` counts ufw's own numbered list — which also // backend models, while `ufw insert` counts ufw's own numbered list — which
// counts the route rules it does not model. Map to that native position so a // also counts tuples it does not model. Map to that native position so an
// route rule earlier in the list does not skew the insert. // unmodeled tuple earlier in the list does not skew the insert.
native, err := f.nativeInsertPosition(position) native, err := f.nativeInsertPosition(position)
if err != nil { if err != nil {
return err return err
@ -1355,37 +1380,41 @@ func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return f.editIPTablesRules(r, true) return f.editIPTablesRules(r, true)
} }
// Protocol-axis removal. A TCPUDP rule is backed either by a single native // Protocol-axis removal. A TCPUDP rule may be backed by a single native
// `any`-proto tuple or by a separately-added tcp tuple + udp tuple, so resolve the // `any`-proto tuple, by a separately-added tcp tuple + udp tuple, or — since
// actual backing before deleting. // ufw treats a differing protocol as a distinct rule — by both at once, so a
// removal sweeps every backing rather than stopping at the first.
if onProtocolAxis(r.Proto) { if onProtocolAxis(r.Proto) {
native, err := f.storedNativeTCPUDP(r) native, err := f.storedNativeTCPUDP(r)
if err != nil { if err != nil {
return err return err
} }
if native != nil { if native != nil {
// Backed by a single native `any` tuple. Delete that tuple via its bare/`any` // Delete the native `any` tuple via its bare/`any` form (a TCPUDP
// form (a TCPUDP marshal of the target). When the target names a single // marshal of the target).
// 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 := *r
del.Proto = TCPUDP del.Proto = TCPUDP
if err := f.deleteNative(ctx, &del); err != nil { if err := f.deleteNative(ctx, &del); err != nil {
return err return err
} }
if s := splitDualRowProtocol(native, r); s != nil {
return f.AddRule(ctx, zoneName, s)
} }
return nil // Delete the concrete-transport tuples: a TCPUDP target expands into
} // tcp+udp, a concrete target deletes itself. deleteNative is a no-op for a
// No native tuple: the rule is backed by concrete-transport tuples. Expand a // tuple ufw does not hold.
// TCPUDP target into tcp+udp and delete each; a concrete target deletes itself.
for _, sub := range expandProtocols(r) { for _, sub := range expandProtocols(r) {
if err := f.deleteNative(ctx, sub); err != nil { if err := f.deleteNative(ctx, sub); err != nil {
return err return err
} }
} }
// When a concrete-transport target consumed a native both-transports
// tuple, re-add the surviving opposite transport so its coverage is kept —
// the protocol analog of splitting a dual-family row on removal. AddRule
// dedups, so an already-present concrete twin is not doubled.
if native != nil {
if s := splitDualRowProtocol(native, r); s != nil {
return f.AddRule(ctx, zoneName, s)
}
}
return nil return nil
} }

View file

@ -94,7 +94,8 @@ func TestUFWRules(t *testing.T) {
}{ }{
{`route:allow tcp 23 0.0.0.0/0 any 0.0.0.0/0 in`, Accept, 23, "", ""}, {`route:allow tcp 23 0.0.0.0/0 any 0.0.0.0/0 in`, Accept, 23, "", ""},
{`route:deny tcp 25 192.168.0.1 any 10.0.0.0/8 in_eth0`, Drop, 25, "eth0", ""}, {`route:deny tcp 25 192.168.0.1 any 10.0.0.0/8 in_eth0`, Drop, 25, "eth0", ""},
{`route:allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in_eth0 out_eth1`, Accept, 80, "eth0", "eth1"}, // ufw stores a dual-interface route rule as one "!"-joined trailing token.
{`route:allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in_eth0!out_eth1`, Accept, 80, "eth0", "eth1"},
} }
for _, rc := range routeCases { for _, rc := range routeCases {
got, err := fw.UnmarshalRule(rc.tuple, IPv4) got, err := fw.UnmarshalRule(rc.tuple, IPv4)
@ -741,3 +742,46 @@ func TestUFWNativeInsertPositionSkipsUnmodeledRows(t *testing.T) {
require.Equal(t, 4, fw.nativeInsertPositionFromRows(withRoute, 3)) require.Equal(t, 4, fw.nativeInsertPositionFromRows(withRoute, 3))
require.Equal(t, 5, fw.nativeInsertPositionFromRows(withRoute, 4)) require.Equal(t, 5, fw.nativeInsertPositionFromRows(withRoute, 4))
} }
// TestUFWEditIPTablesRulesAnchorsFilterCommit pins that the raw-path add splices
// its rule into the *filter section's COMMIT, not the file's first COMMIT: the
// canonical ufw NAT setup puts a *nat block above *filter, and a filter rule in
// that block references an undeclared chain, failing the reload.
func TestUFWEditIPTablesRulesAnchorsFilterCommit(t *testing.T) {
fw := new(UFW)
natFirst := "*nat\n:POSTROUTING ACCEPT [0:0]\n-A POSTROUTING -s 10.0.0.0/8 -o eth0 -j MASQUERADE\nCOMMIT\n" +
"*filter\n:ufw-before-input - [0:0]\nCOMMIT\n"
dir := t.TempDir()
path := filepath.Join(dir, "before.rules")
require.NoError(t, os.WriteFile(path, []byte(natFirst), 0644))
icmp := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}
changed, err := fw.editIPTablesRulesFile(path, icmp, IPv4, false)
require.NoError(t, err)
require.True(t, changed)
data, err := os.ReadFile(path)
require.NoError(t, err)
body := string(data)
natEnd := strings.Index(body, "*filter")
require.NotContains(t, body[:natEnd], "--icmp-type",
"the rule must not land inside the *nat block")
require.Contains(t, body[natEnd:], "--icmp-type",
"the rule must land inside the *filter block")
// The rule sits before *filter's COMMIT.
filterPart := body[natEnd:]
require.Less(t, strings.Index(filterPart, "--icmp-type"), strings.Index(filterPart, "COMMIT"))
}
// TestUFWOldFormatTupleStaysOpaque pins that ufw's old 8-field storage format
// (six core fields plus dapp/sapp, no direction field) is rejected by the tuple
// parser, so parseTupleRows holds it as a nil row rather than mis-reading it.
func TestUFWOldFormatTupleStaysOpaque(t *testing.T) {
fw := new(UFW)
_, err := fw.UnmarshalRule("allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 Apache -", IPv4)
require.Error(t, err, "the old 8-field format must stay opaque")
// A non-route tuple must not carry a "!"-joined interface pair.
_, err = fw.UnmarshalRule("allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in_eth0!out_eth1", IPv4)
require.Error(t, err, "a dual-interface token is only valid on a route rule")
}

View file

@ -15,8 +15,28 @@ func trimQuotes(s string) string {
return strings.Trim(s, "\"'") return strings.Trim(s, "\"'")
} }
// stripUnquotedComment removes a trailing '#' comment from a config line,
// ignoring a '#' inside a quoted value so `KEY = "pre#fix"` is not truncated.
func stripUnquotedComment(line string) string {
var inQuote byte
for i := 0; i < len(line); i++ {
switch c := line[i]; {
case inQuote != 0:
if c == inQuote {
inQuote = 0
}
case c == '"' || c == '\'':
inQuote = c
case c == '#':
return line[:i]
}
}
return line
}
// readConfValue scans a shell-style "KEY = \"VALUE\"" config file (conf.apf, // readConfValue scans a shell-style "KEY = \"VALUE\"" config file (conf.apf,
// csf.conf) for key and returns its value, or "" if key is not set. Used for // csf.conf) for key and returns its value, or "" if key is not set. The last
// assignment wins, matching how the shell sources these files. Used for
// one-shot flags read at construction time, not the per-rule list edits (which // one-shot flags read at construction time, not the per-rule list edits (which
// each backend's own EditConf/EditRulePort scan handles in place). // each backend's own EditConf/EditRulePort scan handles in place).
func readConfValue(path, key string) (string, error) { func readConfValue(path, key string) (string, error) {
@ -25,13 +45,10 @@ func readConfValue(path, key string) (string, error) {
return "", err return "", err
} }
defer func() { _ = fd.Close() }() defer func() { _ = fd.Close() }()
value := ""
scanner := bufio.NewScanner(fd) scanner := bufio.NewScanner(fd)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := strings.TrimSpace(stripUnquotedComment(scanner.Text()))
if ci := strings.IndexByte(line, '#'); ci >= 0 {
line = line[:ci]
}
line = strings.TrimSpace(line)
if line == "" { if line == "" {
continue continue
} }
@ -40,10 +57,10 @@ func readConfValue(path, key string) (string, error) {
continue continue
} }
if strings.TrimSpace(k) == key { if strings.TrimSpace(k) == key {
return trimQuotes(strings.TrimSpace(v)), nil value = trimQuotes(strings.TrimSpace(v))
} }
} }
return "", scanner.Err() return value, scanner.Err()
} }
// runCommand runs command and returns its stdout and any error. The context // runCommand runs command and returns its stdout and any error. The context
@ -146,9 +163,14 @@ func runCommandStdin(ctx context.Context, stdin string, command string, args ...
// Wait for the command to finish. // Wait for the command to finish.
err = cmd.Wait() err = cmd.Wait()
if err != nil { if err != nil {
stderr := strings.TrimSpace(stderrData.String()) // Keep the underlying error wrapped so its exit code stays reachable
if stderr != "" { // through errors.As, and only mention stdout when there is any.
err = fmt.Errorf("%s (stdout %s)", stderr, strings.Join(out, "\n")) stderrText := strings.TrimSpace(stderrData.String())
switch {
case stderrText != "" && len(out) > 0:
err = fmt.Errorf("%s (stdout %s): %w", stderrText, strings.Join(out, "\n"), err)
case stderrText != "":
err = fmt.Errorf("%s: %w", stderrText, err)
} }
// A command can fail and also produce truncated output; surface both so a // A command can fail and also produce truncated output; surface both so a
// read error is never hidden behind the command's own failure. // read error is never hidden behind the command's own failure.

View file

@ -63,6 +63,13 @@ func (f *WF) Capabilities() Capabilities {
Output: true, Output: true,
ICMPv6: true, ICMPv6: true,
PortList: true, PortList: true,
PortPair: true,
// WFP has no negated address condition, no reject action (permit or
// block only), and scopes a filter's family through its address
// conditions, so an address-less rule cannot pin one family.
Negation: false,
RejectAction: false,
FamilyWithoutAddress: false,
ConnState: false, ConnState: false,
InterfaceMatch: false, InterfaceMatch: false,
Logging: false, Logging: false,
@ -198,11 +205,11 @@ func (f *WF) decodeAddress(addr string) (newAddr string, err error) {
newAddr = ip.String() newAddr = ip.String()
} }
// Returned the parsed address. // Return the parsed address.
return return
} }
// Handle single IP // Handle a single IP.
ip := net.ParseIP(addr) ip := net.ParseIP(addr)
if ip == nil { if ip == nil {
err = fmt.Errorf("invalid IP") err = fmt.Errorf("invalid IP")
@ -234,6 +241,17 @@ func (f *WF) UnmarshallFWRule(fr wapi.FWRule) *Rule {
if it := strings.TrimSpace(fr.InterfaceTypes); it != "" && !strings.EqualFold(it, "All") { if it := strings.TrimSpace(fr.InterfaceTypes); it != "" && !strings.EqualFold(it, "All") {
return nil return nil
} }
// A disabled rule enforces nothing: decoding it as live would let AddRule's
// idempotency check treat a disabled twin as satisfying the add (the rule is
// then never enforced) and a Backup capture a rule Restore re-adds enabled.
if !fr.Enabled {
return nil
}
// The model has no edge-traversal field; decoding such a rule identically to
// its non-edge twin would let a rewrite silently narrow it.
if fr.EdgeTraversal {
return nil
}
// Map direction. // Map direction.
if fr.Direction == wapi.NET_FW_RULE_DIR_OUT { if fr.Direction == wapi.NET_FW_RULE_DIR_OUT {
@ -343,8 +361,8 @@ func (f *WF) UnmarshallFWRule(fr wapi.FWRule) *Rule {
} }
} }
// Based on direction, map the source and destination address. // Based on direction, map the source and destination address: the rule model
// Our rule uses source/destination where as windows uses local/remote. // uses source/destination, whereas Windows uses local/remote.
var srcRaw, dstRaw string var srcRaw, dstRaw string
if r.IsOutput() { if r.IsOutput() {
srcRaw = fr.LocalAddresses srcRaw = fr.LocalAddresses
@ -391,7 +409,7 @@ func (f *WF) hasPrefix(fr wapi.FWRule) bool {
// profileFilter maps a zone name to the single Windows profile bit that GetRules // profileFilter maps a zone name to the single Windows profile bit that GetRules
// and RemoveRule filter on, so both scope to the same rules. ok is false when the // and RemoveRule filter on, so both scope to the same rules. ok is false when the
// zone names no specific profile, meaning every profile is in scope (matching an // zone names no specific profile, meaning every profile is in scope.
func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) { func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) {
switch { switch {
case strings.EqualFold(zoneName, "public"): case strings.EqualFold(zoneName, "public"):
@ -405,6 +423,14 @@ func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) {
} }
// profileMatches reports whether a rule's Profiles bitmask is in scope for a // profileMatches reports whether a rule's Profiles bitmask is in scope for a
// zone's profile filter (every rule is, when the zone named no profile). A rule
// matches only when its Profiles exactly equals the single filter bit: AddRule
// always stores a named zone's rule under exactly one profile bit or the
// all-profiles default (never a combination), so an exact-equality test is what
// keeps a specific zone's rules disjoint from another zone's and from an
// all-profiles rule. Testing overlap instead (fr.Profiles&profile != 0) would
// let a single-zone query and, worse, a single-zone RemoveRule/Sync match and
// delete an all-profiles rule — silently affecting every other zone too.
func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool { func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool {
if !useFilter { if !useFilter {
return true return true
@ -472,11 +498,17 @@ func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
if r.IsForward() { if r.IsForward() {
return nil, unsupportedForward("windows firewall") return nil, unsupportedForward("windows firewall")
} }
// A WFP filter likewise carries one direction; a DirAny rule at this row
// level means AddRule/RemoveRule's fan-out was skipped, so reject it rather
// than silently narrow it to inbound (IsOutput is false for DirAny).
if r.Direction == DirAny {
return nil, fmt.Errorf("a both-directions rule must be expanded before marshalling")
}
// Windows Filtering Platform cannot match a port without a concrete // Windows Filtering Platform cannot match a port without a concrete
// protocol; dropping the port would silently widen the rule to match all // protocol; dropping the port would silently widen the rule to match all
// traffic, so reject it instead. // traffic, so reject it instead.
if r.PortNeedsConcreteProtocol() { if r.PortNeedsConcreteProtocol() {
return nil, fmt.Errorf("a port requires a tcp, udp or sctp protocol") return nil, fmt.Errorf("a port requires a tcp or udp protocol")
} }
// Windows only matches ports for TCP and UDP; a port on any other protocol // Windows only matches ports for TCP and UDP; a port on any other protocol
// (e.g. SCTP) has no representation, so reject rather than silently drop it. // (e.g. SCTP) has no representation, so reject rather than silently drop it.
@ -655,20 +687,32 @@ func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
fwRule.Name = strings.Join(nameParts, " ") fwRule.Name = strings.Join(nameParts, " ")
// Set the grouping // Set the grouping.
fwRule.Grouping = f.rulePrefix fwRule.Grouping = f.rulePrefix
return fwRule, nil return fwRule, nil
} }
// AddRule that stored the rule under the all-profiles default). A rule matches only // rejectLogAndLimit reports a logging or rate/connection-limit request on a
// when its Profiles exactly equals this single bit: AddRule always stores a named // backend whose per-rule model cannot express it. Such a rule is rejected
// zone's rule under exactly one profile bit or the all-profiles default (never a // rather than applied without the modifier. It returns nil when the rule asks
// combination), so an exact-equality test is what keeps a specific zone's rules // for none of these. backend names the backend for the error message. WFP is
// disjoint from another zone's and from an all-profiles rule. Testing overlap // the only such backend, so the helper lives here beside its caller.
// instead (fr.Profiles&profile != 0) would let a single-zone query and, worse, a func (r *Rule) rejectLogAndLimit(backend string) error {
// single-zone RemoveRule/Sync match and delete an all-profiles rule — silently switch {
// affecting every other zone too. case r.Log:
return fmt.Errorf("%s does not support per-rule logging in this model: %w", backend, ErrUnsupportedLog)
case r.RateLimit != nil:
return fmt.Errorf("%s does not support rate limiting in this model: %w", backend, ErrUnsupportedRateLimit)
case r.ConnLimit != nil:
return fmt.Errorf("%s does not support connection limiting in this model: %w", backend, ErrUnsupportedConnLimit)
}
return nil
}
// AddRule adds a filter rule scoped to the zone's Windows profile (or the
// all-profiles default for an unnamed zone). It is idempotent: an equivalent
// existing rule makes it a no-op.
func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error { func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
@ -801,11 +845,19 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
// zoneName names a single zone. // zoneName names a single zone.
filterProfile, useFilter := f.profileFilter(zoneName) filterProfile, useFilter := f.profileFilter(zoneName)
// Delete every matching rule. EqualBase ignores the IP family because Windows // Windows deletes by name and removes the FIRST rule bearing it, so a name
// records a concrete family on the rule it lists back even when the added rule // shared with a rule this removal did not match (a per-profile twin of a
// left it unset (mirroring GetRules). Removal is idempotent, matching the other // built-in rule, say) could destroy the wrong rule. Count each name's holders
// backends: a rule that is not present is not an error. // and how many of them this removal matches: when every holder matches,
// repeated by-name deletes are safe; otherwise the delete is ambiguous and is
// refused rather than risked.
nameTotal := map[string]int{}
nameMatched := map[string]int{}
type target struct{ name string }
var targets []target
for _, fr := range fwRules { for _, fr := range fwRules {
nameTotal[fr.Name]++
// Skip rules outside the target profile, matching GetRules' filter. // Skip rules outside the target profile, matching GetRules' filter.
if !f.profileMatches(fr.Profiles, filterProfile, useFilter) { if !f.profileMatches(fr.Profiles, filterProfile, useFilter) {
continue continue
@ -817,14 +869,25 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
continue continue
} }
// EqualBase ignores the IP family because Windows records a concrete
// family on the rule it lists back even when the added rule left it unset
// (mirroring GetRules). Removal is idempotent, matching the other
// backends: a rule that is not present is not an error.
if r.EqualBase(rule, true) { if r.EqualBase(rule, true) {
ok, err := wapi.FirewallRuleDelete(fr.Name) nameMatched[fr.Name]++
targets = append(targets, target{fr.Name})
}
}
for _, t := range targets {
if nameTotal[t.name] != nameMatched[t.name] {
return fmt.Errorf("cannot delete rule %q: another rule shares the name and windows deletes by name", t.name)
}
ok, err := wapi.FirewallRuleDelete(t.name)
if err != nil { if err != nil {
return fmt.Errorf("failed to delete rule %q: %w", fr.Name, err) return fmt.Errorf("failed to delete rule %q: %w", t.name, err)
} }
if !ok { if !ok {
return fmt.Errorf("failed to delete rule %q: reported failure", fr.Name) return fmt.Errorf("failed to delete rule %q: reported failure", t.name)
}
} }
} }
@ -892,7 +955,8 @@ 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. // Backup captures every decodable filter rule in the zone, foreign rules
// included; the library manages the actual firewall state.
func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) { func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName) rules, err := f.GetRules(ctx, zoneName)
if err != nil { if err != nil {

View file

@ -332,3 +332,31 @@ func TestWFProfileMatchesExcludesAllProfilesRule(t *testing.T) {
}) })
} }
} }
// A disabled rule enforces nothing and an edge-traversal rule has no model
// field; both must stay unmodeled — decoding a disabled twin as live would let
// AddRule's idempotency check skip an add the firewall never enforces.
func TestWFUnmarshallDropsDisabledAndEdgeRules(t *testing.T) {
fw := &WF{rulePrefix: "test"}
enabled, err := fw.MarshallFWRule("", &Rule{Proto: TCP, Port: 22, Action: Accept})
require.NoError(t, err)
require.NotNil(t, fw.UnmarshallFWRule(*enabled), "an enabled rule decodes")
disabled := *enabled
disabled.Enabled = false
require.Nil(t, fw.UnmarshallFWRule(disabled), "a disabled rule must stay unmodeled")
edge := *enabled
edge.EdgeTraversal = true
require.Nil(t, fw.UnmarshallFWRule(edge), "an edge-traversal rule must stay unmodeled")
}
// A DirAny rule reaching the row-level marshaller means the AddRule/RemoveRule
// fan-out was skipped; it must be rejected rather than silently narrowed to an
// inbound-only filter (IsOutput is false for DirAny).
func TestWFMarshallRejectsUnexpandedDirAny(t *testing.T) {
fw := &WF{rulePrefix: "test"}
_, err := fw.MarshallFWRule("", &Rule{Direction: DirAny, Proto: TCP, Port: 22, Action: Accept})
require.Error(t, err)
}