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_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.
.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) |
| Comment | no | yes | yes | yes | yes | yes | label | description |
| 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 |
| Connection state | no | yes | yes | yes | yes | yes | no (stateful) | 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 |
| 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
`GetDefaultPolicy`/`SetDefaultPolicy` read and set the default action applied to
@ -355,7 +362,8 @@ construct:
| nftables | a set in the private `inet` table |
| firewalld | a firewalld ipset (D-Bus) |
| pf | a pf table |
| CSF/APF/WFP | unsupported (`ErrUnsupportedSet`) |
| CSF/APF | ipset commands in the managed pre-hook |
| WFP | unsupported (`ErrUnsupportedSet`) |
```go
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
// enforce.
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.
@ -87,24 +92,35 @@ func (f *APF) Capabilities() Capabilities {
return Capabilities{
Output: true,
Forward: true,
// ICMPv6 mirrors ipv6Enabled: with conf.apf's USE_IPV6 off, apf never touches
// ip6tables, so neither its native config nor the raw-iptables hook yields a
// rule apf will keep in sync across a reload (see ipv6Unavailable). Confirmed
// against a real apf install: ip6tables carries no rule for a type in
// IG_ICMPV6_TYPES when USE_IPV6=0.
ICMPv6: f.ipv6Enabled,
PortList: false,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: true,
Comments: true,
// ICMPv6 mirrors ipv6Enabled: with conf.apf's USE_IPV6 off, apf never
// touches ip6tables — an IG_ICMPV6_TYPES entry yields no rule — so neither
// its native config nor the raw-iptables hook yields a rule apf will keep
// in sync across a reload (see ipv6Unavailable).
ICMPv6: f.ipv6Enabled,
// 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,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: 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)
// 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
// APFConf) so this can be exercised against a fixture file in tests.
// absent or the file cannot be read.
func (f *APF) readStopAction(path, key string) Action {
action := Drop
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 {
stops := f.confStopActions()
// A TCPUDP deny is one protocol-less advanced line, which apf applies with
// TCP_STOP on the tcp rule it derives and UDP_STOP on the udp one. It therefore
// has a single native action only when the two settings agree; when they differ
// the rule cannot be one Rule with one Action, so report ActionInvalid and let
// addRule route it to the hook, whose lines carry the action verbatim.
if proto == TCPUDP {
tcp := f.readStopAction(APFConf, "TCP_STOP")
if f.readStopAction(APFConf, "UDP_STOP") != tcp {
if stops["UDP_STOP"] != stops["TCP_STOP"] {
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
@ -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.
// Three collections cooperate here:
// - want: the canonical form of every token this rule contributes, so a
// remove can recognize an existing token as "ours" regardless of
// how it was spelled in the file.
// - 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 holds the canonical form of every token the rule contributes and
// present the canonical form of every kept token, so removal recognizes a
// token however the file spells it and an add never appends a duplicate;
// kept carries the surviving tokens in their original spelling.
want := make(map[string]bool, len(wantTokens))
for _, w := range wantTokens {
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)
var kept []string
// Pass 1: walk the existing config list. Each token is either dropped (only
// on a remove, and only when it is one of the rule's own tokens) or kept.
// We keep the original spelling but track it by canonical form so pass 2 can
// dedupe against it.
// Pass 1: walk the existing config list, dropping a token only on a remove
// and only when it is one of the rule's own; surviving tokens keep their
// original spelling and are tracked by canonical form for the add pass.
for _, tok := range strings.Split(val, ",") {
tok = strings.TrimSpace(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.
// 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 {
// 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)
if err != nil {
return err
@ -902,6 +954,7 @@ func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
defer af.Abort()
// Parse config one line at a time, adding the port rule.
keyMatched := false
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
// Get the line.
@ -933,6 +986,9 @@ func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
val = trimQuotes(strings.TrimSpace(val))
// Parse rules.
if f.confKeyApplies(key, r) {
keyMatched = true
}
orig = f.EditRulePort(orig, key, val, r, remove)
_, _ = fmt.Fprintln(af, orig)
}
@ -944,6 +1000,13 @@ func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
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.
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
// protocol. apf's trust files hold no other portless address shape — a
// 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
// a best-effort single-address write, not a guard.
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
// 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
// conf.apf or the allow_hosts/deny_hosts trust files. The shared shapes
// (ruleNeedsHook, bareHostOneWay, hostNeedsHook, advRuleNeedsHook) and the two apf
// shapes RemoveRule reuses to route a split (dualStackPortNeedsHook, nativeICMPv6)
// keep their own predicates; the apf-only, single-use port/source-port/connlimit/icmp
// tests are inlined here as their sole caller.
// conf.apf or the allow_hosts/deny_hosts trust files. The shared predicates
// (ruleNeedsHook, shapeNeedsHook, bareHostOneWay) and the two apf shapes RemoveRule
// reuses to route a split (dualStackPortNeedsHook, nativeICMPv6) stay standalone;
// the apf-only, single-use port/connlimit/icmp tests are inlined here as their sole
// caller.
func (f *APF) needsHook(r *Rule) bool {
// Features apf's native config cannot model — connection state, per-rule
// 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) {
return true
}
// A one-way bare host has no trust-file form (a plain line is bidirectional, an
// advanced rule needs a port), and a concrete-protocol host or a source+
// destination pair likewise has none (see hostNeedsHook); all go to the hook.
if bareHostOneWay(r) || hostNeedsHook(r) {
// A shape no native apf form holds — a one-way bare host, a source+destination
// pair, a concrete-protocol portless host, an advanced-line address/port-flow
// overflow, or a bare protocol match (see shapeNeedsHook) — goes to the hook. A
// native address-less ICMP/ICMPv6 accept is excluded there and routed below.
if shapeNeedsHook(r) {
return true
}
// A ported source+destination pair, a source port matched with a destination port,
// and an address-less source-port match all overflow the advanced line's single
// address and single port-flow field (see advRuleNeedsHook); iptables matches each
// directly, so they go to the hook.
if advRuleNeedsHook(r) {
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.
// A multi-port tcp/udp list apf's config cannot carry as one rule: its advanced
// rule holds a single port or one underscore range, and a conf.apf CPORTS comma
// list stores each port as an independent token that reads back as its own rule,
// so an address-less multi-port accept would never round-trip whole. Every list
// goes to the hook's iptables multiport match instead, which keeps it on one line.
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
}
// 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) {
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
// only a FamilyAny port is native; a single-family one is written per-family
// 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
}
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)
f.ConfigChanged = f.ConfigChanged || changed
return err
@ -1540,6 +1597,13 @@ func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error {
}
surviving := *r
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)
f.ConfigChanged = f.ConfigChanged || changed
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
// drops it from the CPORTS list and the hook edit drops both per-family rows, and
// each no-ops when the rule is absent — clearing every cell the target covers,
// wherever it lives. Unlike the single-family
// 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 {
if err := f.EditConf(ctx, r, true); err != nil {
return err
@ -1579,6 +1644,14 @@ func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
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
// 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-
@ -1627,9 +1700,6 @@ func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
if f.needsHook(r) {
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.
if r.ConnLimit != nil {
@ -1719,20 +1789,6 @@ func (f *APF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, err
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
// applied in POSTROUTING (postroute.rules), destination NAT in PREROUTING
// (preroute.rules).
@ -1743,14 +1799,6 @@ func (f *APF) natFile(r *NATRule) string {
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
// line for the given family, the form apf's shell-sourced routing files expect.
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 {
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.
@ -1770,9 +1818,15 @@ func (f *APF) natLine(r *NATRule, fam Family) (string, error) {
func (f *APF) editNATFile(r *NATRule, remove bool) error {
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)
for _, fam := range f.natFamilies(r) {
for _, fam := range fams {
line, err := f.natLine(r, fam)
if err != nil {
return err
@ -1875,6 +1929,13 @@ func (f *APF) 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 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)
}
@ -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
// hook rule still references the set; removing an absent set is a no-op.
func (f *APF) RemoveAddressSet(ctx context.Context, name string) error {
changed, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
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.
func (f *APF) Reload(ctx context.Context) error {
// 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 {
_, err := runCommand(ctx, "/etc/apf/apf", "--restart")
if err != nil {
return err
}
f.ConfigChanged = false
}
return nil
}

View file

@ -201,7 +201,7 @@ func TestAPFNATRoundTrip(t *testing.T) {
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
}
for _, orig := range cases {
for _, fam := range fw.natFamilies(orig) {
for _, fam := range natRuleFamilies(orig) {
line, err := fw.natLine(orig, fam)
require.NoError(t, err, "marshal %+v", *orig)
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
// 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.
// 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) {
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}),
@ -741,7 +741,7 @@ func TestAPFPortOnlyDenyHealsAndDoesNotDuplicate(t *testing.T) {
// 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
// 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.
func TestAPFBareHostWritten(t *testing.T) {
fw := new(APF)
@ -757,8 +757,9 @@ func TestAPFBareHostWritten(t *testing.T) {
// 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
// port token (stays native); an address-less tcp/udp accept list lives in
// conf.apf's CPORTS lists; a non-tcp/udp match is left to iptablesRuleValid.
// port token (stays native); an address-less tcp/udp accept list is hooked too,
// 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) {
fw := new(APF)
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 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},
{"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 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},
@ -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
// existing IPv4 port-only deny; the family-agnostic EqualBase check previously
// 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")
require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCPUDP, Port: 80, Action: Accept}),
"a single-family tcpudp port has no dual-stack CPORTS form")
// An address-less multi-port accept is native: the CPORTS lists are comma lists.
// The same ports against an address are not — apf's advanced rule holds one port.
require.False(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}),
"a dual-stack tcpudp port list lives in the CPORTS lists")
// An address-less multi-port accept is hooked: a CPORTS entry reads back one
// rule per token, so the hook's multiport line is the only round-tripping form.
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}),
"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}},
Source: "192.0.2.1", Action: Accept}),
"apf's advanced rule carries no port list")
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, SourcePort: 80, Action: Accept}),
"an address-less source-port match has no advanced-rule form")
// A portless tcpudp host has no plain-line form (a plain line is all-protocol).
require.True(t, hostNeedsHook(&Rule{Proto: TCPUDP, Source: "192.0.2.1", Action: Accept}))
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
@ -1023,3 +1037,52 @@ func TestAPFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
require.NotContains(t, string(data), "d=80",
"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").
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 {
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 {
return err
}
@ -158,11 +165,15 @@ func captureBackupState(ctx context.Context, mgr Manager, zoneName string, b *Ba
if caps.DefaultPolicy {
// Best-effort: a backend that cannot report a single coherent default policy
// (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
// surfaced from the GetRules call the Backup makes before this, which reads the
// same source; leaving the field nil then makes Restore leave the policy as it
// finds it.
if policy, err := mgr.GetDefaultPolicy(ctx, zoneName); err == nil {
// rather than failing the whole backup, and Restore then leaves the policy as
// it finds it. A cancelled context is not that case — it would silently
// produce a snapshot missing the policy a default-drop host depends on — so
// it still fails the backup.
policy, err := mgr.GetDefaultPolicy(ctx, zoneName)
if err != nil && ctx.Err() != nil {
return err
}
if err == nil {
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
// 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
// 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 {
if b == nil || !mgr.Capabilities().AddressSets {
return nil

View file

@ -10,7 +10,7 @@ require (
require (
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/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/cabbie v1.0.2 // indirect
@ -27,8 +27,3 @@ require (
)
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
// 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.
// 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) {
if resolved, err := filepath.EvalSymlinks(dst); err == nil {
dst = resolved
}
mode := defaultMode
var uid, gid int
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
// 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)
}
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)
}
}
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)
}
// Install the staged file.
@ -89,14 +100,14 @@ func (a *atomicFile) Abort() {
if a.done {
return
}
_ = a.File.Close()
_ = a.Close()
_ = os.Remove(a.tmp)
a.done = true
}
// fail closes and removes the temp file, then returns the triggering error.
func (a *atomicFile) fail(err error) error {
_ = a.File.Close()
_ = a.Close()
_ = os.Remove(a.tmp)
a.done = true
return err

View file

@ -155,3 +155,28 @@ func TestAtomicFileStagesInDestinationDir(t *testing.T) {
require.True(t, strings.HasPrefix(filepath.Base(af.tmp), "conf.tmp."))
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 {
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"
// Return the new csf object.
return csf, nil
}
@ -96,23 +95,31 @@ func (f *CSF) Capabilities() Capabilities {
// ip6tables, so neither its native config nor the raw-iptables hook yields a
// rule csf will keep in sync across a reload (see ipv6Unavailable).
ICMPv6: f.ipv6Enabled,
// A csf.conf port list (TCP_IN="80,443,...") stores each port independently
// and reads back as one rule per port, so a discrete multi-port rule does
// not round-trip as a single rule (a range, kept as one token, does). Report
// PortList as unsupported to reflect that; callers open several ports by
// adding a rule per port, which is how csf stores them anyway.
PortList: false,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: true,
Comments: true,
// PortList is honored through the pre-hook: a csf.conf port list
// (TCP_IN="80,443,...") stores each port independently and reads back as
// one rule per port, so an address-less multi-port accept is injected as a
// single `-m multiport` iptables line instead (see multiPortConfAccept).
// An addressed multi-port rule is native: an advanced line's port field
// is a comma list.
PortList: true,
PortPair: true,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: 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
}
@ -344,32 +359,16 @@ func (f *CSF) ParseIPList(filePath string, action Action) (rules []*Rule, err er
comment, hasPrefix := flushComment()
if strings.Contains(trimmed, "|") {
rule := f.ParseAdvRule(trimmed, action)
if rule == nil {
continue
}
rule.Comment = comment
rule.HasPrefix = hasPrefix
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,
})
// parseListLine classifies an advanced line (pipe or colon delimited) or a
// 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 {
continue
}
rule.Comment = comment
rule.HasPrefix = hasPrefix
rules = append(rules, rule)
}
_ = 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)...)
case "UDP_OUT":
rules = append(rules, f.ParsePorts(val, IPv4, UDP, true)...)
case "TCP6_IN":
rules = append(rules, f.ParsePorts(val, IPv6, TCP, false)...)
case "TCP6_OUT":
rules = append(rules, f.ParsePorts(val, IPv6, TCP, true)...)
case "UDP6_IN":
rules = append(rules, f.ParsePorts(val, IPv6, UDP, false)...)
case "UDP6_OUT":
rules = append(rules, f.ParsePorts(val, IPv6, UDP, true)...)
case "TCP6_IN", "TCP6_OUT", "UDP6_IN", "UDP6_OUT":
// With IPV6 off csf never applies the *6_* lists, so their entries
// are inert; reporting them would claim IPv6 coverage the firewall
// does not enforce.
if !f.ipv6Enabled {
continue
}
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":
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 —
// otherwise a Drop rule the caller manages reads back as Reject, never
// 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()
ipRules, err = f.ParseIPList(CSFDeny, dropIn)
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 {
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":
if !r.IsOutput() || r.Family == IPv4 || r.Proto == UDP {
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":
if r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
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":
if !r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
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":
// CONNLIMIT tokens are "port;limit", edited independently of the port
// 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.
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)
if err != nil {
return err
@ -726,7 +761,7 @@ func (f *CSF) EditConf(ctx context.Context, r *Rule, remove bool) error {
for scanner.Scan() {
// Get the line.
orig := scanner.Text()
line := orig[:]
line := orig
// Remove comments.
ci := strings.IndexByte(line, '#')
@ -834,11 +869,17 @@ func (f *CSF) parseListLine(line string, action Action) *Rule {
if strings.Contains(line, "|") {
return f.ParseAdvRule(line, action)
}
family, ok := parseAddrFamily(line)
if !ok {
return nil
if family, ok := parseAddrFamily(line); ok {
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
@ -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
// protocol. csf.allow/csf.deny hold no other portless address shape — a
// 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
// a best-effort single-address write, not a guard.
addr := match.Source
@ -890,7 +931,7 @@ func (f *CSF) listRows(action Action, match *Rule) []listRow {
}
for _, sub := range expandProtocols(fam) {
// 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.
row := *sub
if row.IsOutput() {
@ -1051,26 +1092,13 @@ func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r
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
// 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
// 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,
// bareHostOneWay, hostNeedsHook, advRuleNeedsHook, bareProtoNeedsHook) keep their own
// predicates, since RemoveRule routes on ruleNeedsHook and bareHostOneWay directly.
// csf.conf or the csf.allow/csf.deny lists. The shared predicates (ruleNeedsHook,
// shapeNeedsHook, bareHostOneWay) stay standalone, since APF shares them and
// RemoveRule routes on ruleNeedsHook and bareHostOneWay directly.
func (f *CSF) needsHook(r *Rule) bool {
// Features csf's native config cannot express (connection state, per-rule
// interface, logging, rate limiting, forward-chain routing, icmpv6, a transport
@ -1078,17 +1106,18 @@ func (f *CSF) needsHook(r *Rule) bool {
if ruleNeedsHook(r) {
return true
}
// A one-way bare host has no csf.allow/csf.deny form (a plain line is
// bidirectional, an advanced rule needs a port), and a concrete-protocol host or a
// source+destination pair likewise has none (see hostNeedsHook); all go to the hook.
if bareHostOneWay(r) || hostNeedsHook(r) {
// A shape no native csf form holds — a one-way bare host, a source+destination
// pair, a concrete-protocol portless host, an advanced-line address/port-flow
// overflow, or a bare protocol match (see shapeNeedsHook) — goes to the hook.
if shapeNeedsHook(r) {
return true
}
// A ported source+destination pair, a source port matched with a destination port,
// and an address-less source-port match all overflow the advanced line's single
// address and single port-flow field (see advRuleNeedsHook); iptables matches each
// directly, so they go to the hook.
if advRuleNeedsHook(r) {
// An address-less multi-port accept goes to the hook's `-m multiport` match:
// a csf.conf port list stores each port as an independent token that reads
// back as its own rule, so the list shape has no single native form (see
// multiPortConfAccept). An addressed multi-port rule stays native — an
// advanced line's port field is a comma list.
if f.multiPortConfAccept(r) {
return true
}
// 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) {
return true
}
// An ICMPv4 rule csf's advanced-rule format cannot carry (see nativeICMP) goes to
// the hook's `iptables -p icmp` match, which needs neither an address nor a type.
if r.Proto == ICMP && !f.nativeICMP(r) {
return true
}
// A bare protocol match with no address and no port has no native csf construct —
// csf.conf keys on a port, an advanced rule on address+port, and csf.allow/csf.deny
// on an address — but iptables expresses it directly, so it goes to the hook (see
// bareProtoNeedsHook). ICMP is excluded there and routed above.
return bareProtoNeedsHook(r)
// An ICMPv4 rule csf's advanced-rule format cannot carry goes to the hook's
// `iptables -p icmp` match, which needs neither an address nor a type. The only
// native form is exactly one address with a concrete type: an advanced line
// 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 != ""))
}
// multiPortConfAccept reports whether a rule is an address-less tcp/udp accept
// carrying a discrete multi-port list — the one port shape a csf.conf port list
// cannot hold as a single rule: TCP_IN="80,443" stores independent tokens that
// 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,
@ -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/
// 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
// non-native connection limit, a non-native ICMPv4 rule, or a bare protocol match)
// is injected as a raw iptables rule through the csf pre-hook. See needsHook for
// each clause; everything past this gate maps onto csf's own config files.
// pair, a source-and-destination port match, an address-less source-port match,
// an address-less multi-port accept, a non-native connection limit, a non-native
// ICMPv4 rule, or a bare protocol match) is injected as a raw iptables rule
// 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) {
_, err := f.hook().edit(r, false)
return err
@ -1182,12 +1228,12 @@ func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6
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 {
err := f.EditConf(ctx, r, false)
if err != nil {
return err
}
return f.EditConf(ctx, r, false)
}
// 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
}
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)
return err
}
@ -1301,6 +1354,14 @@ func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
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
// 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 —
@ -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
// 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
}
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.
if r.ConnLimit != nil {
@ -1433,40 +1494,82 @@ func (f *CSF) UnmarshalNATRule(line string) *NATRule {
return r
}
// GetNATRules reads the NAT rules from csf.redirect.
func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
fd, err := os.Open(CSFRedirect)
if err != nil {
// csf.redirect is optional; a missing file simply has no rules.
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
// natNeedsHook reports whether a NAT rule has no csf.redirect form and must be
// injected as a raw nat-table command through the pre-hook instead. csf.redirect
// encodes destination NAT only, in exactly two DNAT shapes plus the local
// redirect, always tcp/udp on a single concrete port, with no source or
// interface match (see MarshalNATRule). iptables expresses all of the excess —
// source NAT, an interface binding, a port range — directly, so those shapes are
// hooked rather than rejected.
func (f *CSF) natNeedsHook(r *NATRule) bool {
if r.Kind.isSource() {
return true
}
defer func() { _ = fd.Close() }()
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
}
// 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
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := scanner.Text()
if ci := strings.IndexByte(line, '#'); ci >= 0 {
line = line[:ci]
fd, err := os.Open(CSFRedirect)
switch {
case err == nil:
defer func() { _ = fd.Close() }()
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := scanner.Text()
if ci := strings.IndexByte(line, '#'); ci >= 0 {
line = line[:ci]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if r := f.UnmarshalNATRule(line); r != nil {
rules = append(rules, r)
}
}
line = strings.TrimSpace(line)
if line == "" {
continue
if err := scanner.Err(); err != nil {
return nil, err
}
if r := f.UnmarshalNATRule(line); r != nil {
rules = append(rules, r)
}
}
if err := scanner.Err(); err != nil {
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
// rule in it carries the configured prefix; HasPrefix stays false (mirroring
// firewalld's zones).
return rules, nil
// firewalld's zones). Hook NAT lines are raw iptables commands, so a rule this
// 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
@ -1491,7 +1594,8 @@ func (f *CSF) redirectPort(p uint16) string {
// ("IPx|portA|IPy|portB|proto"). csf.redirect expresses only destination NAT: a
// Redirect to a local port (IPy = "*") or a DNAT forward to another host
// (IPy = ToAddress). Source NAT, port ranges/lists, and non-tcp/udp protocols
// are not representable.
// 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) {
if err := r.validate(); err != nil {
return "", err
@ -1603,8 +1707,24 @@ func (f *CSF) editRedirect(r *NATRule, remove bool) error {
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 {
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)
}
@ -1614,8 +1734,20 @@ func (f *CSF) InsertNATRule(ctx context.Context, zoneName string, position int,
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 {
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)
}
@ -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
// hook rule still references the set; removing an absent set is a no-op.
func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error {
_, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
return err

View file

@ -171,16 +171,16 @@ func TestCSFAdvRuleTCPUDPWithAddressFansOut(t *testing.T) {
// unenforceable line.
func TestCSFAdvRuleAddressWithoutPortRouted(t *testing.T) {
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")
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")
// 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
// iptables rule needs no type.
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")
}
@ -306,12 +306,54 @@ func TestCSFSourcePorts(t *testing.T) {
// 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
// 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")
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")
}
// 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
// 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
@ -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.
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")
line := fw.MarshalAdvRule(typed)
require.Equal(t, "icmp|in|d=8|s=1.2.3.4", line)
@ -339,7 +380,6 @@ func TestCSFICMPRouting(t *testing.T) {
}
h := &hookScript{}
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)
lines, err := h.rulesToLines(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
// 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.
// 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) {
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")
}
@ -672,17 +712,17 @@ func TestCSFDNATMarshalRejectsUnexpressible(t *testing.T) {
// (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
// 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) {
for _, r := range []*Rule{
{Proto: TCP, Action: Accept},
{Proto: ProtocolAny, Action: Accept},
{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)
}
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")
}
@ -739,7 +779,7 @@ func TestCSFPortOnlyRejectRoundTrip(t *testing.T) {
// 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
// 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.
func TestCSFBareHostWritten(t *testing.T) {
fw := new(CSF)
@ -925,3 +965,110 @@ func TestCSFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
require.NotContains(t, string(data), "d=80",
"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
// 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
// sentinel is preserved for programmatic handling.
var (
@ -112,7 +112,8 @@ func (t Family) String() string {
}
// 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) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "any":
@ -238,10 +239,12 @@ var icmpNameToNum = map[string]uint8{
"traceroute": 30,
}
// icmpv6NameToNum maps the ICMPv6 type names nftables and other tools print to
// their numeric type. ICMPv6 reuses several names from 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.
// icmpv6NameToNum maps the ICMPv6 type names nftables, ip6tables and ufw print
// to their numeric type (both the nftables nd-* spellings and the ip6tables
// long forms such as router-solicitation). ICMPv6 reuses several names from
// 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{
"destination-unreachable": 1,
"packet-too-big": 2,
@ -257,10 +260,17 @@ var icmpv6NameToNum = map[string]uint8{
"mld-listener-done": 132,
"mld-listener-reduction": 132,
"nd-router-solicit": 133,
"router-solicitation": 133,
"nd-router-advert": 134,
"router-advertisement": 134,
"nd-neighbor-solicit": 135,
"neighbor-solicitation": 135,
"neighbour-solicitation": 135,
"nd-neighbor-advert": 136,
"neighbor-advertisement": 136,
"neighbour-advertisement": 136,
"nd-redirect": 137,
"redirect": 137,
"router-renumbering": 138,
"ind-neighbor-solicit": 141,
"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
// 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 {
switch {
case strings.EqualFold("udp", proto):
@ -828,6 +841,10 @@ type Rule struct {
// backends. It is unexported and informational — it backs HasPrefix for the
// container backends and, like HasPrefix, is not part of rule identity.
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
@ -888,6 +905,12 @@ func (r *Rule) HasSourcePorts() bool {
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
// (a list, or a range spanning more than one port). Backends limited to a single
// 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
// 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
// protocol) or emitting an invalid rule. UFW is the exception and can express a
// bare port across any protocol.
// protocol) or emitting an invalid rule. A ported `any` in a ufw tuple is not this
// shape: it means tcp+udp and is modeled as TCPUDP.
func (r *Rule) PortNeedsConcreteProtocol() bool {
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
// 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
// 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 {
if r.impliedFamily() != rule.impliedFamily() {
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.
// outputHonored has Equal's meaning.
func (r *Rule) EqualBase(rule *Rule, outputHonored bool) bool {
return r.matchFields(rule, outputHonored)
}
// coversDirection reports whether an existing rule in direction have already
// covers a caller rule in direction want (the asymmetric add/dedup form). DirAny
// coversDirection reports whether an existing rule's direction (have) already
// 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
// 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
// unconditional — behavior identical to the pre-DirAny code.
// output concept, so input and output never distinguish two rules — but a
// forward rule is still its own chain and only covered by another forward rule.
func coversDirection(have, want Direction, outputHonored bool) bool {
if !outputHonored {
return true
return (have == DirForward) == (want == DirForward)
}
if have == want {
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.
func coversDirectionRemoval(a, b Direction, outputHonored bool) bool {
if !outputHonored {
return true
return (a == DirForward) == (b == DirForward)
}
if a == b {
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
// redundant on add: the same base rule, and the receiver's family and direction
// both cover o's. It is the family- and direction-aware add guard the container
// redundant on add: the same base rule, with the receiver's family, direction
// 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,
// 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
@ -1175,7 +1200,7 @@ func (r *Rule) EqualForDedup(o *Rule, outputHonored bool) bool {
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
// 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.
@ -1222,16 +1247,16 @@ func (r *Rule) Covers(o *Rule) bool {
// 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
// direction each touch the row's. It is the overlap relation a removal walks the
// stored rows with, so a target removes every row it covers (rule 3) and also
// matches a row that covers more than the target, which the backend then deletes and
// re-adds minus the targeted cell (splitMergedRow, rule 4). A FamilyAny/TCPUDP/DirAny
// target matches every row on that axis, such a row matches any target, and otherwise
// the values must match so acting on one twin never disturbs the other. Family and
// direction are checked first so a non-matching row skips the field compare, which
// runs in the inbound frame (canonicalMatch) with direction excluded.
// stored rows with: a target removes every row it covers, and also matches a row
// that covers more than the target, which the backend then deletes and re-adds
// minus the targeted cell (splitMergedRow). A FamilyAny/TCPUDP/DirAny target
// matches every row on that axis, such a row matches any target, and otherwise
// the values must match so acting on one twin never disturbs the other. The
// field compare runs in the inbound frame (canonicalMatch) with direction
// excluded, since the axis gates above already decided it.
func (r *Rule) EqualForRemoval(o *Rule, outputHonored bool) bool {
ft, fr := o.impliedFamily(), r.impliedFamily()
if !(ft == FamilyAny || fr == FamilyAny || ft == fr) {
if ft != FamilyAny && fr != FamilyAny && ft != fr {
return false
}
if !coversDirectionRemoval(r.Direction, o.Direction, outputHonored) {
@ -1280,6 +1305,21 @@ func splitDualRow(matched, target *Rule) *Rule {
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
// 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
@ -1428,7 +1468,7 @@ func splitMergedRow(matched, target *Rule) []*Rule {
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
// 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.
@ -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
// model cannot express network address translation. backend names the backend.
func unsupportedNAT(backend string) error {
@ -1661,8 +1685,10 @@ func (k NATKind) String() string {
return "invalid"
}
// ParseNATKind parses a NAT-kind token (case-insensitive), accepting the canonical
// name emitted by NATKind.String. An unknown value is an error.
// ParseNATKind parses a NAT-kind token (case-insensitive), accepting only the
// 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) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "dnat":
@ -1673,8 +1699,6 @@ func ParseNATKind(s string) (NATKind, error) {
return SNAT, nil
case "masquerade":
return Masquerade, nil
case "invalid":
return NATInvalid, nil
}
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
// ranges are supported by every backend, so they are not advertised.)
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 bool
// 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
// silently ignores the Comment field.
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
@ -2171,7 +2222,8 @@ type Manager interface {
// cannot manage a default policy return an unsupported 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)
// GetAddressSet returns a single address set by name, or an error if it does
@ -2326,10 +2378,22 @@ func Sync(ctx context.Context, mgr Manager, zoneName string, desired []*Rule) (a
}
toAdd = append(toAdd, d)
}
if err := AddRules(ctx, mgr, zoneName, toAdd); err != nil {
return added, removed, err
// 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
}
added = len(toAdd)
return added, removed, nil
}
for _, r := range toAdd {
if err := mgr.AddRule(ctx, zoneName, r); err != nil {
return added, removed, err
}
added++
}
added = len(toAdd)
return added, removed, nil
}

View file

@ -1689,3 +1689,45 @@ func TestNATCoveredBy(t *testing.T) {
require.True(t, dnatAny.CoveredBy([]*NATRule{dnatV4}),
"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 manages a firewalld instance over D-Bus, mapping the Manager
// interface onto firewalld's zones, rich rules, and ipsets.
type FirewallD struct {
Conn *firewalld.Conn
// rulePrefix is accepted for a consistent constructor signature across
// backends. firewalld organizes rules into zones rather than a private
// namespace, so the prefix is not applied to individual rules.
rulePrefix string
}
// NewFirewallD connects to firewalld and returns a manager, or an error when
// firewalld cannot be reached.
func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) {
@ -35,21 +45,27 @@ func (f *FirewallD) Type() string {
// Capabilities reports which optional features this backend supports.
func (f *FirewallD) Capabilities() Capabilities {
return Capabilities{
Output: false,
Zones: true,
Priority: true,
ICMPv6: true,
PortList: false,
ConnState: false,
InterfaceMatch: false,
Logging: true,
RateLimit: true,
ConnLimit: false,
NAT: true,
RuleOrdering: false,
DefaultPolicy: true,
RuleCounters: false,
AddressSets: true,
Output: false,
Zones: true,
Priority: true,
ICMPv6: true,
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,
InterfaceMatch: false,
Logging: true,
RateLimit: true,
ConnLimit: false,
NAT: true,
RuleOrdering: false,
DefaultPolicy: true,
RuleCounters: false,
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.
// 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
// error means the interface is not bound to a zone, so we fall through to
// the default zone below.
// error means the interface is not bound to a zone, leaving the default
// zone below.
zoneName, err = f.Conn.Permanent().ZoneOfInterface(ctx, iface)
if err == nil && zoneName != "" {
return zoneName, nil
}
// If we did not find a zone for the specified interface, use the default
// zone if it exists.
// An unbound interface belongs to the default zone.
defaultZone, derr := f.Conn.DefaultZone(ctx)
if derr == nil && defaultZone != "" {
return defaultZone, nil
}
// If we were unable to find the zone or a default zone, error out.
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
// 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=") {
address := trimQuotes(strings.TrimPrefix(tokens[i], "address="))
if not {
@ -233,7 +250,6 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
} else {
r.Destination = address
}
r.Direction = DirOutput
} else if strings.HasPrefix(tokens[i], "ipset=") {
ipset := trimQuotes(strings.TrimPrefix(tokens[i], "ipset="))
if not {
@ -241,7 +257,6 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
} else {
r.Destination = ipset
}
r.Direction = DirOutput
} else {
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++
}
// 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" {
// 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.
@ -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
// 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
// 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++
if i >= len(tokens) || !strings.HasPrefix(tokens[i], "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
num, ok := f.icmpTypeNumber(isV6, trimQuotes(strings.TrimPrefix(tokens[i], "name=")))
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)
}
// 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 {
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
// firewalld refuses with MISSING_FAMILY.
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
// 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 {
// firewalld has no output chain (Capabilities().Output is false); it models a
// "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)
if r.Direction == DirOutput {
r = r.canonicalMatch()
}
// 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
@ -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)
}
// 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.
zoneName, err := f.resolveZoneName(ctx, zoneName)
zoneName, err = f.resolveZoneName(ctx, zoneName)
if err != nil {
return err
}
@ -970,18 +1041,27 @@ func (f *FirewallD) ignoreNotEnabled(err error) error {
return err
}
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager
// interface onto firewalld's zones, rich rules, and ipsets.
type FirewallD struct {
Conn *firewalld.Conn
// rulePrefix is accepted for a consistent constructor signature across
// backends. firewalld organizes rules into zones rather than a private
// namespace, so the prefix is not applied to individual rules.
rulePrefix string
// removeZoneEntry removes the zone-level entry — a zone port, source port, or
// protocol — that a zone-entry-shaped rule maps to, reporting whether the rule's
// shape matched one of those forms. The firewalld error (including NOT_ENABLED)
// is returned uninterpreted; RemoveRule's zone-entry shortcut and its dual-stack
// split share this so their form routing cannot drift apart.
func (f *FirewallD) removeZoneEntry(ctx context.Context, zone *firewalld.PermZone, r *Rule) (bool, error) {
switch {
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
// 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 {
if _, _, err := net.ParseCIDR(s); err == nil {
return true
@ -998,12 +1078,17 @@ func (f *FirewallD) isZoneSource(s string) bool {
return false
}
// RemoveRule uses it to decide whether a bare source can be cleared with
// RemoveSource rather than falling through to the rich-rule path.
// RemoveRule removes a filter rule from a zone, clearing every form it may be
// 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 {
// 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)
if r.Direction == DirOutput {
r = r.canonicalMatch()
}
// 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
@ -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
// the port/source shortcuts below. Logging and rate limiting also force the
// 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
// element is absent as a zone entry) falls through to the rich-rule path in
// case the rule was stored in that form out of band.
// firewalld's typed errors: a real error is returned, while success and
// NOT_ENABLED both continue to the rich-rule sweep — a rule present in both
// 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) {
// A single destination port (or contiguous range) maps to a zone port entry.
// Mirror AddRule: key on HasPorts (so a single port in the Ports slice
// matches) and require a concrete port protocol.
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
if matched, rerr := f.removeZoneEntry(ctx, zone, r); matched {
if rerr != nil && !errors.Is(rerr, firewalld.ErrNotEnabled) {
return rerr
}
} else if r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && 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) {
} else if f.sourceZoneShape(r) && f.isZoneSource(r.Source) {
// 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
// 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
// rich-rule path below.
// sourceZoneShape mirrors AddRule's ProtocolAny guard: a source+protocol
// rule is a rich rule, so it must be removed as one rather than clearing
// 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) {
// of them. A value that is not a zone source form is left to the rich-rule
// path below; sourceZoneShape mirrors AddRule's ProtocolAny guard, since a
// source+protocol rule is stored as a rich rule.
if err := zone.RemoveSource(ctx, r.Source); err != nil && !errors.Is(err, firewalld.ErrNotEnabled) {
return err
}
}
@ -1088,19 +1151,7 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
rAny := *r
rAny.Family = FamilyAny
if f.zoneEntryEligible(&rAny) && r.Source == "" {
var removeErr error
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 matched, removeErr := f.removeZoneEntry(ctx, zone, r); matched {
if removeErr == nil {
if s := splitDualRow(&rAny, r); s != nil {
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 {
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
}
}
@ -1187,7 +1238,7 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
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)
if err != nil {
return nil, err
@ -1195,32 +1246,12 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
// 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
// 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 {
pr, perr := ParsePortRange(fp.Port)
if perr != nil {
continue
if rule := f.modeledForwardPort(fp); rule != nil {
rules = append(rules, rule)
}
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)
}
// Zone masquerade maps to a single Masquerade rule.
@ -1237,6 +1268,43 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
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
// per-zone port-forward API expects (port, protocol, toport, toaddr). That API
// 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.)
func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) {
if r.Proto != TCP && r.Proto != UDP {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol")
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol: %w", ErrUnsupportedNAT)
}
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()
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 != "" {
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 != "" {
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{
Port: specs[0].String(),
@ -1401,7 +1469,10 @@ func (f *FirewallD) SetDefaultPolicy(ctx context.Context, zoneName string, polic
// --- 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) {
settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx)
if errors.Is(err, firewalld.ErrInvalidIPSet) {
@ -1410,9 +1481,14 @@ func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet
if err != nil {
return nil, err
}
set := &AddressSet{Name: name, Entries: settings.Entries, Type: SetHashIP}
if settings.Type == "hash:net" {
set := &AddressSet{Name: name, Entries: settings.Entries}
switch settings.Type {
case "hash:ip":
set.Type = SetHashIP
case "hash:net":
set.Type = SetHashNet
default:
return nil, nil
}
switch settings.Options["family"] {
case "inet6":
@ -1568,13 +1644,22 @@ func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup
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 {
if len(f.zonePortRules([]firewalld.Port{port}, false)) == 0 {
continue
}
if err := zone.RemovePort(ctx, port); err != nil {
return err
}
}
for _, sp := range settings.SourcePorts {
if len(f.zonePortRules([]firewalld.Port{sp}, true)) == 0 {
continue
}
if err := zone.RemoveSourcePort(ctx, sp); err != nil {
return err
}
@ -1585,18 +1670,28 @@ func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup
}
}
for _, proto := range settings.Protocols {
if GetProtocol(proto) == ProtocolAny {
continue
}
if err := zone.RemoveProtocol(ctx, proto); err != nil {
return err
}
}
for _, richRule := range settings.RichRules {
if _, perr := f.UnmarshalRichRule(richRule); perr != nil {
continue
}
if err := zone.RemoveRichRule(ctx, richRule); err != nil {
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 {
if f.modeledForwardPort(fp) == nil {
continue
}
if err := zone.RemoveForwardPort(ctx, fp); err != nil {
return err
}

View file

@ -35,17 +35,24 @@ func TestFirewallDZonePortRulesSkipsUnmodeledProto(t *testing.T) {
func TestFirewallDRichRules(t *testing.T) {
fw := new(FirewallD)
// Parse a rule that is expected to parse right.
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`)
// Parse a rule that is expected to parse right: an action-scoped limit
// (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)
// 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)
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,
"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.
invalidRules := []string{
`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
// round-trip, rather than being rejected or read back as invisible. Unlike a
// source ipset, firewalld rejects a familyless destination ipset with
// MISSING_FAMILY (verified against the real firewalld.core.rich parser), so the
// caller must supply a concrete Family.
// MISSING_FAMILY, so the caller must supply a concrete Family.
func TestFirewallDDestinationIPSet(t *testing.T) {
fw := new(FirewallD)
@ -207,7 +213,8 @@ func TestFirewallDDestinationIPSet(t *testing.T) {
parsed, err := fw.UnmarshalRichRule(got)
require.NoError(t, err)
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.
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
// is left unqualified by family and still round-trips correctly. Live testing
// against firewalld 2.4.3 confirmed it accepts a familyless rich rule carrying
// `protocol value="ipv6-icmp"` just like any other protocol (its rich.py check()
// only requires a family for a source/destination address, never for a bare
// protocol match), and the protocol value string alone ("icmp" vs "ipv6-icmp")
// tells the read path ICMP from ICMPv6 without needing a family qualifier.
// is left unqualified by family and still round-trips correctly. firewalld only
// requires a family for a source/destination address, never for a bare protocol
// match, and the protocol value string alone ("icmp" vs "ipv6-icmp") tells the
// read path ICMP from ICMPv6.
func TestFirewallDBareICMPFamilyPinned(t *testing.T) {
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.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
replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0
require (
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/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847
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,
// per-rule interface matching, logging, rate limiting, ICMPv6, a transport
// protocol their native config does not model (SCTP and the portless IP protocols
// GRE, ESP and AH), or an address-set reference (@set) — csf.allow/apf trust files
// take literal addresses only, so a `-m set --match-set` match lives in the hook
// beside the ipset commands that create the set.
// GRE, ESP and AH), an address-set reference (@set), or a negated address.
// csf.allow/apf trust files take literal addresses only, so a `-m set
// --match-set` match lives in the hook beside the ipset commands that create the
// set, 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 {
return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" ||
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
@ -164,79 +169,63 @@ func dirAnyPlainLine(r *Rule) bool {
return r.Direction == DirAny && bareHostShape(r) && !ruleNeedsHook(r)
}
// hostNeedsHook reports whether an address-bearing rule with no port must be
// injected through the raw-iptables hook because a csf/apf trust file has no form
// for it: a source+destination pair (both store a single address) or a host
// pinned to a concrete tcp/udp protocol (the plain line is all-protocol and the
// advanced rule requires a port). Both are expressed natively as an iptables
// rule. A single-address all-protocol host is not covered here — a one-way one is
// routed by bareHostOneWay, a bidirectional one is a native plain line — and ICMP
// keeps its own handling (CSF.nativeICMP, APF.isConfRule), so it is excluded. Shared
// by CSF and APF.
func hostNeedsHook(r *Rule) bool {
if r.Proto.IsICMP() || r.HasPorts() || r.HasSourcePorts() {
return false
}
// A source+destination pair has no single-address advanced/plain form.
if r.Source != "" && r.Destination != "" {
return true
}
// A single-address host pinned to a transport has no portless form: the plain
// line is all-protocol and the advanced rule requires a port. TCPUDP counts —
// it names transports, so it is not the all-protocol plain line either.
if r.Source != "" || r.Destination != "" {
return onProtocolAxis(r.Proto)
}
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.
// shapeNeedsHook reports whether a rule's shape overflows every native CSF/APF
// config form and must be injected through the raw-iptables hook. The native forms
// are narrow — a plain trust-file line holds one address, matches both directions,
// and is all-protocol; an advanced line holds exactly one address field and one
// port-flow field and requires both; the conf lists key on a port or an icmp type —
// while iptables expresses every overflow directly (`-s` with `-d`, `--sport` with
// `--dport`, a bare `-p tcp -j ACCEPT`), so these shapes 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
}
// One port-flow field: a source port and a destination port cannot share it.
if r.HasPorts() && r.HasSourcePorts() {
// Feature-based routing (state, interface, logging, rate limiting, ICMPv6,
// hook-only transports, set references, negation) is ruleNeedsHook's job, and the
// 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
}
// An advanced rule requires an address, so a bare source-port match has no
// advanced form at all; iptables matches --sport on its own.
if r.HasSourcePorts() && r.Source == "" && r.Destination == "" {
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.
if r.Source != "" && r.Destination != "" {
return true
}
// A single-address host pinned to a transport has no portless form: the plain
// line is all-protocol and the advanced rule requires a port. TCPUDP counts —
// it names transports, so it is not the all-protocol plain line either.
if (r.Source != "" || r.Destination != "") && onProtocolAxis(r.Proto) {
return true
}
}
// One address field: a source+destination pair cannot share it. Only the advanced
// form is at stake, so a rule with neither a port nor an icmp match — which never
// reaches MarshalAdvRule — is left to hostNeedsHook.
if r.Source != "" && r.Destination != "" {
return r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()
// 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.
if r.HasPorts() && r.HasSourcePorts() {
return true
}
// An advanced rule requires an address, so a bare source-port match has no
// advanced form at all; iptables matches --sport on its own.
if r.HasSourcePorts() && r.Source == "" && r.Destination == "" {
return true
}
// One address field: a ported or icmp-matching source+destination pair cannot
// share it (its portless non-ICMP counterpart is routed above).
if r.Source != "" && r.Destination != "" &&
(r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()) {
return true
}
}
return false
}
// bareProtoNeedsHook reports whether a rule is a bare protocol match — a non-ICMP
// transport with no address and no port — that CSF/APF cannot express in their
// 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 {
// A bare protocol match — a non-ICMP transport with no address and no port — has
// no native construct (the trust files key on an address, the conf lists on a
// port or icmp type) but iptables applies it directly.
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
// iptables marshaller quotes free-text fields (a comment, a log prefix) with
// strconv.Quote — Go double-quoting, which is right for an iptables-restore file
// but NOT for the hook script, which /bin/sh sources: inside double quotes the
// shell still expands $var, $(...) and backticks. A token made of ordinary
// argument characters is returned bare for readability; anything else is wrapped
// in single quotes (with any embedded single quote escaped), which the shell
// treats as a literal. shlex.Split reverses either form on read-back.
// iptables marshaller quotes free-text fields (a comment, a log prefix) for an
// iptables-restore file, where double quotes are literal — but the hook is
// sourced by /bin/sh, which expands $var, $(...) and backticks inside double
// quotes. A token made of ordinary argument characters is returned bare for
// readability; anything else is wrapped in single quotes (with any embedded
// single quote escaped), which the shell treats as a literal. shlex.Split
// reverses either form on read-back.
func shellSafeToken(tok string) string {
safe := 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
// 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) {
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))
}
// 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
// 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
@ -390,6 +433,26 @@ func ruleMatchesAny(e *Rule, targets []*Rule) bool {
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
// (one line, so a LOG line yields a rule with Log set and no action), reporting
// whether the line is one this backend recognizes.
@ -429,9 +492,8 @@ func (h *hookScript) commandLines() ([]string, error) {
}
var cmds []string
for _, line := range lines {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "iptables ") || strings.HasPrefix(t, "ip6tables ") {
cmds = append(cmds, t)
if isHookRuleLine(line) {
cmds = append(cmds, strings.TrimSpace(line))
}
}
return cmds, nil
@ -472,8 +534,9 @@ func (h *hookScript) readHookLines() ([]string, bool, error) {
return strings.Split(content, "\n"), true, nil
}
// writeHook atomically replaces the hook with lines, giving a freshly created
// hook a shebang, and keeps it executable so the firewall can source it.
// writeHook atomically replaces the hook with lines. A freshly created hook
// 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 {
var b strings.Builder
if !existed {
@ -483,8 +546,6 @@ func (h *hookScript) writeHook(lines []string, existed bool) error {
b.WriteString(l)
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 {
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
// 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
// matches on the underlying rule: a line is dropped when the rule it encodes is
// the same as one r resolves to, ignoring the comment tag, so a copy of the rule a
// matches on the underlying rule: a hook rule is dropped when it is the same rule
// 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
// is not part of rule identity. A logged rule's LOG and action lines are matched
// independently, exactly as they were written. 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.
// is not part of rule identity. A LOG line and the action line under it are
// matched as the one logged rule they encode, never independently: a logged rule
// and its unlogged twin are distinct rules, and removing one must not strip the
// 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) {
// 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).
@ -520,23 +583,50 @@ func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
changed := false
if remove {
// Parse each desired line back into the rule it encodes; a hook line whose own
// rule matches one of these (comment ignored, see Rule.Equal) is dropped. A line
// that is not a rule the hook recognizes (foreign shell, a comment) never parses
// and is preserved.
var targets []*Rule
// Parse each desired line back into the rule it encodes — the round trip
// normalizes field spellings — and coalesce each LOG line with its action
// line so the targets are logical rules. A desired line that fails to
// parse breaks the marshal/parse round trip the removal match depends on,
// so it is an error rather than a silent no-op.
var parsed []*Rule
for _, l := range desired {
if tr, ok := h.parseLine(l); ok {
targets = append(targets, tr)
tr, ok := h.parseLine(l)
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]
for _, l := range lines {
if er, ok := h.parseLine(l); ok && ruleMatchesAny(er, targets) {
changed = true
for i := 0; i < len(lines); i++ {
er, ok := h.parseLine(lines[i])
if !ok {
next = append(next, lines[i])
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 {
return false, nil
@ -562,6 +652,191 @@ func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
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) -----------------------------
//
// 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 {
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 {
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
// sets — is preserved; it reports whether the hook changed.
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()
if err != nil {
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
// commands that create the set.
{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 {
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
// that overflows either must reach the raw-iptables hook, which matches -s/-d and
// --sport/--dport together: before advRuleNeedsHook existed, AddRule fell through to
// MarshalAdvRule and failed on a rule the firewall can enforce.
func TestAdvRuleNeedsHook(t *testing.T) {
// Shapes the single address / single port-flow field cannot hold.
// shapeNeedsHook routes every rule shape the csf/apf native forms cannot hold —
// a one-way bare host, a portless source+destination pair or transport-pinned
// host, an advanced-line address/port-flow overflow, and a bare protocol match —
// to the raw-iptables hook, while the shapes a plain line, an advanced line, or a
// conf list expresses (and the ICMP shapes each backend routes itself) stay off it.
func TestShapeNeedsHook(t *testing.T) {
// Shapes with no native csf/apf form.
needs := []*Rule{
// A source+destination pair carrying a port: hostNeedsHook bows out once a
// rule has a port, so this predicate is what routes it.
// A one-way bare host: a plain line is bidirectional, an advanced rule needs a
// 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: 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},
// 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, 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: UDP, Port: 53, SourcePort: 53, Destination: "192.0.2.1", Action: Accept},
{Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept},
@ -63,25 +83,37 @@ func TestAdvRuleNeedsHook(t *testing.T) {
// advanced form of any kind.
{Proto: TCP, SourcePort: 1234, Action: Accept},
{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 {
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{
// 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.
{Proto: TCP, Port: 22, 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},
// A portless source+destination pair belongs to hostNeedsHook.
{Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
// An address-less port rule: a native conf port list.
{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
// 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, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
}
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
// 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
@ -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
// 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.
@ -737,3 +713,285 @@ func TestHookScriptRemoveSweepsV6AfterIPv6Disabled(t *testing.T) {
"a stale ip6tables line must be swept on removal, not stranded in the hook")
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 (
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
)
@ -39,3 +43,260 @@ func hookPlanter(mgr Manager) func(*Rule) error {
}
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 {
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"
"errors"
"fmt"
"net"
"os"
"os/exec"
"strings"
"testing"
"time"
@ -47,6 +47,26 @@ type backendFactory struct {
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
// 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
@ -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")
})
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) {
requireCap(t, caps.Output)
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})
})
t.Run("hosthooknoport", func(t *testing.T) {
// csf/apf trust files store a single all-protocol address and their advanced
// rule holds a single port, so a portless concrete-protocol host, a
// source+destination pair, and (for apf) a multi-port list have no trust-file
// form. apf routes all of these to the raw-iptables hook (hostNeedsHook /
// needsHook); csf routes the host shapes to the hook and expresses a
// multi-port list natively as a comma list. Either way each must round-trip.
// Gated to csf/apf; the chain backends express these natively (covered
// elsewhere).
switch mgr.Type() {
case CSFType, APFType:
default:
t.Skip("only csf/apf route these shapes to the hook")
}
// A concrete-protocol host with no port.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Source: "192.0.2.40/32", Action: Accept})
// 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})
// 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
// (stock DROP). Probe both actions and round-trip the first the backend
// accepts, mirroring the denyaddress case.
roundTripVariants(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: Drop},
)
// 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.
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})
t.Run("hostnoport", func(t *testing.T) {
// Portless host shapes. csf/apf trust files store a single all-protocol
// address and their advanced rule holds a single port, so a portless
// concrete-protocol host, a source+destination pair, and (for apf) a
// multi-port list have no trust-file form there and route to the
// raw-iptables hook (shapeNeedsHook / needsHook), while csf expresses a
// multi-port list natively as a comma list. The chain backends express the
// same shapes natively. Every backend runs every shape so a bug in
// whichever path expresses it shows up; a backend that cannot express a
// shape at all skips via the ErrUnsupported sentinel.
t.Run("protohost", func(t *testing.T) {
// A concrete-protocol host with no port.
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.
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
// 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
// accepts, mirroring the denyaddress case.
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: Drop},
)
})
t.Run("multiporthost", func(t *testing.T) {
// 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.
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) {
// A csf/apf advanced line holds one address field and one port-flow field, so a
// ported source+destination pair and a source-plus-destination port match both
// overflow it. Neither backend may reject them: iptables matches -s with -d and
// --sport with --dport directly, so AddRule routes both to the raw-iptables hook
// (advRuleNeedsHook). Gated to csf/apf; the chain backends express these
// natively and cover them elsewhere.
switch mgr.Type() {
case CSFType, APFType:
default:
t.Skip("only csf/apf route these shapes to the hook")
}
// A source+destination pair carrying a destination port. hostNeedsHook stops at
// 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})
// The same pair on a source port, and on a typed ICMP match.
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: 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.
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: 8445, SourcePort: 5355, Action: Accept})
t.Run("matchcombos", func(t *testing.T) {
// Combined matches that overflow a csf/apf advanced line — it holds exactly
// one address field and one port-flow field — so both route them to their
// raw-iptables hook (shapeNeedsHook); the chain backends match -s with -d
// and --sport with --dport directly. Every backend runs every combo; the
// only capability in play is PortPair (firewalld's rich rules carry a
// single port element). Port 22 is avoided in every field so an SSH-driven
// run keeps its session.
t.Run("portedpair", func(t *testing.T) {
// A source+destination pair carrying a destination port: the second address
// 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
// gates it, so the round trip is unconditional.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8443, Source: "192.0.2.50/32", Destination: "198.51.100.50/32", Action: Accept})
})
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})
})
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})
})
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})
})
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})
})
})
t.Run("negatedaddress", func(t *testing.T) {
// ufw's tuple grammar cannot negate an address, so a negated source is routed
// to the before.rules raw path (iptables `! -s`) rather than rejected, and
// must round-trip through it. Gated to ufw: this exercises that specific
// reroute; other backends negate (or reject) through their own paths.
if mgr.Type() != UFWType {
t.Skip("exercises ufw's negated-address reroute to before.rules")
}
requireCap(t, caps.Negation)
// A negated source must round-trip through whatever path expresses it:
// iptables/nft/pf/firewalld negate natively, ufw's tuple grammar cannot so
// it reroutes to the before.rules raw path (iptables `! -s`), and csf/apf
// route it to their raw-iptables hook. wf advertises Negation false (WFP
// 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})
})
@ -273,44 +350,44 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
)
})
t.Run("denyactionfromconfig", func(t *testing.T) {
// csf.deny / apf deny_hosts encode no action of their own: the tool applies
// its deny chain with an action taken from config — csf.conf DROP (stock
// default DROP), apf conf.apf ALL_STOP (stock default DROP). A deny whose
// action matches that is stored natively and must read back with it, not a
// fixed Reject — or a caller managing a Drop rule sees it churn on every Sync
// (read back as Reject, never equal to the desired Drop). A deny whose action
// differs has no native form but is expressible directly in iptables, so these
// backends inject it through their pre-hook rather than refusing it; it must
// round-trip with its exact action. Only the csf/apf address-list backends
// derive a deny's action from config this way.
switch mgr.Type() {
case "csf", "apf":
default:
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},
t.Run("denyexactaction", func(t *testing.T) {
// A deny must read back with the exact action it was added with, or a
// caller managing it sees churn on every Sync (read back as one action,
// never equal to the desired other). The stakes are highest on csf/apf,
// whose deny stores encode no action of their own — the tool applies an
// action taken from config (csf.conf DROP / conf.apf ALL_STOP, stock
// default DROP), so a deny whose action matches config is stored natively
// and one that differs is injected through their pre-hook rather than
// refused — but the property holds for every backend, so both actions run
// everywhere. A backend with no reject action at all (wf) advertises
// RejectAction false and skips that action by capability.
for _, tc := range []struct {
name string
deny *Rule
}{
{"drop", &Rule{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.30/32", Action: Drop}},
{"reject", &Rule{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.31/32", Action: Reject}},
} {
require.NoError(t, mgr.AddRule(ctx, zone, deny),
"a deny must be storable natively or through the hook, never refused: %+v", deny)
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, deny) })
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) })
// 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.
got := findRule(t, ctx, mgr, zone, deny)
require.Equal(t, deny.Action, got.Action,
"a deny must read back with its exact action, not the config's default: %+v", deny)
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 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.
got := findRule(t, ctx, mgr, zone, deny)
require.Equal(t, deny.Action, got.Action,
"a deny must read back with its exact action, not a config default: %+v", deny)
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)
require.NoError(t, mgr.RemoveRule(ctx, zone, deny))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), deny, mgr.Capabilities().Output),
"deny rule still present after removal: %+v", deny)
require.NoError(t, mgr.RemoveRule(ctx, zone, deny))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), deny, mgr.Capabilities().Output),
"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
// the backend reports must clear them all. Regression for a family-strict
// remove that could not match a family-agnostic rule at all (a silent no-op
// that left the port open) or that removed only one of the two rows.
// 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
v4 := &Rule{Family: IPv4, 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
// 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
// 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
// dual-stack port lists, wf's address-less filters) it rejects the removal with
// ErrUnsupported instead. Regression for a concrete-family removal that dropped
// both families (nft/pf over-remove) or no-oped and left both (firewalld zone
// entries under-remove).
// family must leave the other in place — the backend splits the dual object
// (apf splits its dual-stack port list through the raw-iptables hook). The
// split is gated on FamilyWithoutAddress: wf's address-less filters cannot
// pin one family, so it advertises the capability false. Regression for a
// concrete-family removal that dropped both families (nft/pf over-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.
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
// 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) {
requireCap(t, caps.FamilyWithoutAddress)
coverage := func() (v4Cov, v6Cov bool) {
for _, r := range rulesOf(t, ctx, mgr, zone) {
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)
}
// Remove IPv4; IPv6 must survive. A backend that cannot express a
// single-family rule of this shape rejects with ErrUnsupported, so skip.
if err := mgr.RemoveRule(ctx, zone, c.v4); errors.Is(err, ErrUnsupported) {
t.Skip("backend cannot express a single-family removal of this dual-family shape")
} else {
require.NoError(t, err)
}
// Remove IPv4; IPv6 must survive. FamilyWithoutAddress is required
// above, so a single-family removal of the shape must be expressible.
require.NoError(t, mgr.RemoveRule(ctx, zone, c.v4))
has4, has6 := 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")
@ -1007,40 +1087,34 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
)
})
t.Run("csfnativeicmp", func(t *testing.T) {
// csf's only native ICMP form is an advanced rule carrying exactly one address
// and a concrete type; every other shape routes to the hook (nativeICMP), so
// the shared icmp/icmptype probes match csf on its bare hook form first.
// Round-trip the native form here to keep the csf.allow advanced-rule path
// covered. Gated to csf; type 13 (Timestamp) avoids echo-request collisions.
if mgr.Type() != CSFType {
t.Skip("csf-specific advanced-rule ICMP path")
}
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.71/32", Action: Accept})
t.Run("icmptypedhost", func(t *testing.T) {
// A typed ICMP match carrying a host address. The shared icmp/icmptype
// probes stop at the first form a backend accepts, which hides this one:
// it is csf's only NATIVE ICMP form (a csf.allow advanced line carries
// exactly one address and a concrete type; every other shape routes to its
// hook, see CSF.needsHook), and on apf — whose native ICMP is the address-less
// IG_ICMP_TYPES list — the shape that routes to the hook (needsHook). Every
// backend runs it so both paths, and everyone else's addressed ICMP match,
// stay covered. Type 13 (Timestamp) avoids colliding with Windows'
// 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) {
// apf carries ICMP as a zone-wide allowed-type list (IG_ICMP_TYPES), so an
// ICMP rule with an address or a non-accept action has no native form and
// routes to the hook (needsHook). The shared icmp/icmptype probes match
// apf on its native address-less accept form first, so exercise the hook path
// 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("icmptypeddrop", func(t *testing.T) {
// A typed ICMP drop with no address. A non-accept action has no place in
// apf's allowed-type list, so it routes to apf's hook; the other backends
// express it directly or skip via the ErrUnsupported sentinel.
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Drop})
})
t.Run("apfsinglefamilyport", func(t *testing.T) {
// apf's CPORTS lists are dual-stack, so a single-family bare port accept has
// no native form and is written per-family through the hook
// (dualStackPortNeedsHook) rather than rejected. It must round-trip on its
// own; the FamilyAny→single-family split is exercised by the destport split
// test. Gated to apf.
if mgr.Type() != APFType {
t.Skip("apf-specific single-family CPORTS-to-hook routing")
}
t.Run("singlefamilyport", func(t *testing.T) {
requireCap(t, caps.FamilyWithoutAddress)
// A single-family bare port accept. Most backends store it directly; apf's
// CPORTS port lists are dual-stack, so it has no native form there and is
// written per-family through the hook (dualStackPortNeedsHook) rather than
// rejected. wf advertises FamilyWithoutAddress false (a WFP filter scopes
// family through its address conditions) and is gated out. The
// 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})
})
@ -1106,29 +1180,29 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
)
})
t.Run("apfnativeconnlimit", func(t *testing.T) {
// apf writes a native connection limit — a single inbound tcp/udp port, no
// address, per-source, rejecting the excess — to conf.apf's CLIMIT lists
// rather than the hook (isConnLimitRule). apf now also routes every other
// connection-limit shape to the hook (needsHook), so the shared
// connlimit probe matches apf on its global hook form first; round-trip the
// native form here to keep the conf.apf CLIMIT path covered. Gated to apf.
if mgr.Type() != APFType {
t.Skip("apf-specific conf.apf CLIMIT path")
}
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
t.Run("connlimitpersource", func(t *testing.T) {
requireCap(t, caps.ConnLimit)
// A per-source connection cap on a single address-less inbound tcp port,
// rejecting the excess. The shared connlimit probe stops at the first form
// a backend accepts, which hides this one wherever a global form matched
// first: on csf and apf it is the single NATIVE connection-limit shape
// (csf.conf CONNLIMIT / conf.apf CLIMIT) while every other shape routes to
// their hook, and nft counts it in a named meter set. Every
// 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
// 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) {
// A csf.deny entry carries no action of its own — csf applies csf.conf's action
// by direction — so the deny of an address is a single entry there and must be
// removable whatever action the caller names, as apf's deny_hosts entry is.
// Otherwise RemoveRule reports success while csf keeps enforcing the entry.
// Gated to csf; the stock csf.conf DROP is "DROP", so Drop is the native inbound
// deny action and Reject is the differing one.
if mgr.Type() != CSFType {
t.Skip("csf-specific csf.deny action-agnostic removal")
}
t.Run("denyremovedanyaction", func(t *testing.T) {
requireCap(t, caps.DenyActionFromConfig)
// On a backend whose native deny store carries no per-entry action (the
// tool applies its config's action — see DenyActionFromConfig), the deny of
// an address is a single entry (csf.deny, apf deny_hosts.rules) and must be
// removable whatever action the caller names. Otherwise RemoveRule reports
// success while the tool keeps enforcing the entry. The stock config action
// is DROP on both, so Drop is the native deny action and Reject the
// differing one.
host := "192.0.2.72/32"
added := &Rule{Family: IPv4, Proto: TCP, Port: 8084, Source: host, Action: Drop}
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))
for _, r := range rulesOf(t, ctx, mgr, zone) {
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) {
requireCap(t, caps.Comments)
// 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),
"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"))
sets, err = mgr.GetAddressSets(ctx)
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))
})
// --- 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 -----------------------------------------------------
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
// 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
// must equal the order after a backup/remove/restore cycle.
if !orderedFilterBackend(mgr.Type()) {
t.Skip("backend has no first-match chain order to preserve")
}
// must equal the order after a backup/remove/restore cycle. Gated on
// RuleOrdering: a backend whose filter rules form a first-match chain
// 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}
var rules []*Rule
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) {
// The Windows Filtering Platform maps a zone to a firewall profile; AddRule
// and GetRules scope to it, so RemoveRule must too. Removing a rule from one
// profile must not delete an identical rule in another. Only the wf backend
// models per-zone profiles this way; the others share one rule space.
if mgr.Type() != "windows-firewall" {
t.Skip("backend does not scope rules to per-zone profiles")
t.Run("zones", func(t *testing.T) {
requireCap(t, caps.Zones)
// A zones backend maps interfaces to zones: GetZone with no interface
// names the default zone, an interface bound to another zone (seeded out
// of band, permanent config only) resolves to that zone, and an unbound
// interface keeps resolving to the default. The binding targets the
// 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")
}
target := "trusted"
if def == target {
target = "work"
}
undo, err := seeder("lo", target)
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")
})
t.Run("rulecounters", func(t *testing.T) {
requireCap(t, caps.RuleCounters)
// A backend advertising RuleCounters must populate Packets/Bytes from the
// live firewall. Drive real traffic through a managed rule: an egress
// accept toward a TEST-NET address, dialed with a short timeout — the SYN
// 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))
counted := func() bool {
for _, got := range rulesOf(t, ctx, mgr, zone) {
if got.EqualBase(r, mgr.Capabilities().Output) && got.Packets > 0 {
return true
}
}
return false
}
// 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}
require.NoError(t, mgr.AddRule(ctx, "public", r))
require.NoError(t, mgr.AddRule(ctx, "private", r))
t.Cleanup(func() {
_ = mgr.RemoveRule(ctx, "public", r)
_ = mgr.RemoveRule(ctx, "private", r)
})
candidates := []string{"public", "private", "home", "work", "domain", "internal", "dmz"}
pub, err := mgr.GetRules(ctx, "public")
require.NoError(t, err)
require.True(t, containsRule(pub, r, mgr.Capabilities().Output), "rule should be present in the public profile")
priv, err := mgr.GetRules(ctx, "private")
require.NoError(t, err)
require.True(t, containsRule(priv, r, mgr.Capabilities().Output), "rule should be present in the private profile")
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")
}
// Remove from the private profile only; the public copy must survive.
require.NoError(t, mgr.RemoveRule(ctx, "private", r))
priv, err = mgr.GetRules(ctx, "private")
require.NoError(t, err)
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)
require.True(t, containsRule(pub, r, mgr.Capabilities().Output), "removing from the private profile must not delete the public rule")
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) {
// firewalld stores a MAC (or ipset) zone source, which GetRules reports as a
// bare-source rule. RemoveRule's source shortcut previously only handled an
// IP/CIDR, so a foreign MAC source could never be removed — Sync/Restore could
// not converge on it. Seed one out of band in the permanent config the backend
// reads and confirm the library both surfaces and removes it. Only firewalld
// models MAC/ipset zone sources this way.
if mgr.Type() != "firewalld" {
t.Skip("backend does not model MAC/ipset zone sources")
// A backend may store a MAC zone source (firewalld), which GetRules reports
// as a bare-source rule. RemoveRule's source shortcut previously only handled
// an IP/CIDR, so a foreign MAC source could never be removed — Sync/Restore
// could not converge on it. Seed one out of band in the permanent config the
// backend reads and confirm the library both surfaces and removes it. The
// generic foreign-rule sweep is the foreignrule subtest; the seeding
// commands are inherently backend-specific (foreignMACSeeder), so a backend
// 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"
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-source="+mac).CombinedOutput(); err != nil {
t.Skipf("could not seed a foreign MAC source (firewall-cmd: %v): %s", err, out)
seed, err := seeder(zone)
if err != nil {
t.Skipf("could not seed a foreign MAC source: %v", err)
}
t.Cleanup(func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-source="+mac).Run()
})
t.Cleanup(seed.undo)
want := &Rule{Source: mac, Action: Accept}
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
"a foreign MAC zone source should surface in GetRules")
require.NoError(t, mgr.RemoveRule(ctx, zone, want))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
"RemoveRule must remove a foreign MAC zone source")
})
t.Run("foreignprotocol", func(t *testing.T) {
// firewalld stores a bare-protocol allow (firewall-cmd --add-protocol) as a
// zone protocol entry, distinct from the rich-rule form the library writes.
// GetRules must surface it and RemoveRule must remove it, or a foreign
// --add-protocol is invisible to Sync/Restore. Only firewalld models this.
if mgr.Type() != "firewalld" {
t.Skip("backend does not model zone protocol entries")
// A backend may store a bare-protocol allow as its own construct distinct
// from the rule form the library writes (firewalld's zone protocol entries,
// seeded with firewall-cmd --add-protocol). GetRules must surface it and
// RemoveRule must remove it, or a foreign protocol allow is invisible to
// Sync/Restore. Like foreignmacsource, the seeding is backend-specific
// (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"
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-protocol="+proto).CombinedOutput(); err != nil {
t.Skipf("could not seed a foreign protocol (firewall-cmd: %v): %s", err, out)
seed, err := seeder(zone)
if err != nil {
t.Skipf("could not seed a foreign protocol: %v", err)
}
t.Cleanup(func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-protocol="+proto).Run()
})
t.Cleanup(seed.undo)
want := &Rule{Proto: GRE, Action: Accept}
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
"a foreign zone protocol should surface in GetRules")
require.NoError(t, mgr.RemoveRule(ctx, zone, want))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
"RemoveRule must remove a foreign zone protocol")
})
t.Run("afterrulesexcluded", func(t *testing.T) {
// ufw writes and removes raw rules only in before.rules, so GetRules must not
// surface a rule from after.rules — otherwise Backup captures it and Restore
// re-adds it into before.rules, duplicating it. Only the ufw backend has this
// before/after split.
if mgr.Type() != "ufw" {
t.Skip("backend has no after.rules file")
// A backend may keep a raw rules file it deliberately does not manage (ufw's
// after.rules; the library writes raw rules only into before.rules). GetRules
// must not surface a rule from it — otherwise Backup captures it and Restore
// re-adds it into the managed file, duplicating it. The seeding edits the
// backend's own file (unmanagedRawRuleSeeder), so a backend without one skips.
seeder := unmanagedRawRuleSeeder(mgr)
if seeder == nil {
t.Skip("backend has no unmanaged raw rules file to seed")
}
const afterPath = "/etc/ufw/after.rules"
orig, err := os.ReadFile(afterPath)
probe, undo, err := seeder()
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),
"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 ----------------------------------------------------------------
// 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) {
t.Helper()
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) })
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,
// since the capability under test claimed the feature works.
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()
for _, rule := range variants {
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.
}
require.NoError(t, err)
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
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
roundTripAdded(t, ctx, mgr, zone, rule)
return true
}
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

View file

@ -84,7 +84,7 @@ func probeDebianLayout(root string) (l iptLayout, ok bool) {
// probeRHELLayout reports the RHEL/iptables-services layout
// (/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
// 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
@ -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
// 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
// case. Only when the RHEL v4 path is entirely absent do we probe the
// Debian iptables-persistent layout instead.
// Debian layout), since what manages the firewall in that case is unknown.
// Only when the RHEL v4 path is entirely absent is the Debian
// iptables-persistent layout probed instead.
var layout iptLayout
if _, err := os.Stat("/etc/sysconfig/iptables"); err == nil {
l, ok := probeRHELLayout("")
@ -137,8 +137,8 @@ func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) {
return nil, errors.New(IPTablesNoService)
}
// If ip6tables service is missing, we do not want to modify iptables
// as we do not know what may be used for the firewall. Skip the redundant
// A missing ip6tables service means something unknown manages the v6
// firewall, so iptables must not be modified either. Skip the redundant
// check when it is the same service already confirmed enabled above (the
// Debian layout uses one service for both families).
ipt.IP6Service = layout.ip6Service
@ -164,21 +164,25 @@ func (f *IPTables) Type() string {
// Capabilities returns the set of features this backend can express.
func (f *IPTables) Capabilities() Capabilities {
return Capabilities{
Output: true,
Forward: true,
ICMPv6: true,
PortList: true,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: true,
DefaultPolicy: true,
RuleCounters: true,
AddressSets: true,
Comments: true,
Output: true,
Forward: true,
ICMPv6: true,
PortList: true,
PortPair: true,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: true,
DefaultPolicy: true,
RuleCounters: true,
AddressSets: 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
case "-p", "--protocol":
// We do not support negation on this parameter.
// Negation is unsupported on this parameter.
if not {
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")
}
// Confirm we can parse the address.
// Confirm the address parses.
_, _, err := net.ParseCIDR(tokens[i])
ip := net.ParseIP(tokens[i])
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")
}
// Confirm we can parse the address.
// Confirm the address parses.
_, _, err := net.ParseCIDR(tokens[i])
ip := net.ParseIP(tokens[i])
if err != nil && ip == nil {
@ -434,7 +438,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
}
r.OutInterface = tokens[i]
case "-j", "--jump":
// We do not support negation on this parameter.
// Negation is unsupported on this parameter.
if not {
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
case "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 tokens[i+1] == "--reject-with" {
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])
}
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).
if not {
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)
}
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) {
return nil, fmt.Errorf("invalid multiport match")
}
switch tokens[i+1] {
case "--dports", "--dport", "--sports", "--sport", "--ports", "--port":
case "--dports", "--dport", "--sports", "--sport":
default:
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":
// Invalid protocol define.
// Reject an unknown protocol token.
if r.Proto == 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":
// SCTP carries ports like UDP and iptables-save spells its port
// match module `-m sctp`, so it shares this branch.
// Invalid protocol define.
// Reject an unknown protocol token.
if r.Proto == 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])
}
// 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
}
@ -861,7 +873,11 @@ func coalesceLoggedRules(rules []*Rule) []*Rule {
// parseFilterFile reads a family's iptables-save file and returns its filter
// rules as logical rules, coalescing each LOG line with the action line that
// follows it. Lines that do not parse (nat rules, custom chains) are skipped.
// 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) {
fd, err := os.Open(path)
if err != nil {
@ -895,6 +911,10 @@ func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error)
}
r, err := f.UnmarshalRule(line, family)
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
}
// 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 {
return nil, err
}
// Return the coalesced rules; GetRules assigns Number once both save files are
// read (the other callers use the result only for dedup and never read Number).
return coalesceLoggedRules(perLine), nil
// Coalesce LOG pairs positionally — exactly as logicalStarts numbers them —
// then compact the unmodeled slots away; GetRules assigns Number once both
// 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.
@ -931,11 +965,62 @@ func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule
numberByDirection(v4)
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, v6...)
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,
// 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
@ -1082,11 +1167,11 @@ func iptChainForDirection(d Direction) string {
// logicalStarts maps each entry of rules (the parsed `-A` lines of one chain,
// in file order) to the 1-based logical-rule position it begins, or 0 when it is
// not a logical-rule start — an action line coalesced into a preceding LOG line,
// or a dropped orphan LOG line. It mirrors coalesceLoggedRules exactly so an
// insert/move position aligns with the per-chain numbering GetRules reports: a
// LOG line paired with its action is one logical rule beginning at the LOG line,
// while an orphan LOG line (no matching action after it) begins none. The
// not a logical-rule start — an unmodeled (nil) foreign line, an action line
// coalesced into a preceding LOG line, or a dropped orphan LOG line. It mirrors
// GetRules' numbering exactly — Rule.Number promises to mirror the InsertRule/
// MoveRule position argument, and GetRules skips lines it cannot parse — so a
// caller position never lands relative to a rule GetRules did not report. The
// returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and
// prepareMoveRuleFile can translate a caller position into a physical line.
func (f *IPTables) logicalStarts(rules []*Rule) []int {
@ -1094,7 +1179,12 @@ func (f *IPTables) logicalStarts(rules []*Rule) []int {
pos := 0
for i := 0; i < len(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
if i+1 < len(rules) {
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
// up to but not including the `-j <target>`), including any rate/connection
// 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) {
// 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
@ -1435,11 +1526,12 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
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 {
return nil, err
}
defer func() { _ = fd.Close() }()
af, err := newAtomicFile(filePath, 0644)
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
// position GetRules reports and never splits a logged rule's two lines or a
// fanned-out tcp/udp pair.
allLines, err := f.readAllLines(filePath)
if err != nil {
af.Abort()
return nil, err
}
chainStarts := f.chainStarts(allLines, expectedChain)
chainIdx := 0
scanner := bufio.NewScanner(fd)
filterFound := false
writtenRule := false
writeRule := func() {
@ -1475,8 +1561,8 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
writtenRule = true
}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
for _, raw := range allLines {
line := strings.TrimSpace(raw)
if !filterFound {
if line == "*filter" {
@ -1504,16 +1590,9 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
_, _ = 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 {
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
@ -1706,7 +1785,7 @@ func (f *IPTables) prepareMoveRuleFile(filePath string, r *Rule, position int) (
}
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)
@ -1748,7 +1827,12 @@ func (f *IPTables) parseNATTarget(tok string) (addr string, port uint16) {
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) {
tokens, err := shlex.Split(spec, true)
if err != nil {
@ -1819,18 +1903,37 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error
r.Destination = tokens[i]
}
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++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid interface parameter")
}
r.Interface = tokens[i]
case "-p", "--protocol":
if not {
return nil, fmt.Errorf("negated protocol match is not modeled")
}
i++
if i >= len(tokens) {
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":
if not {
return nil, fmt.Errorf("negated port match is not modeled")
}
i++
if i >= len(tokens) {
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}
}
case "-m", "--match":
if not {
return nil, fmt.Errorf("negated match is not modeled")
}
i++
if i >= len(tokens) {
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")
}
switch tokens[i+1] {
case "--dports", "--dport", "--ports", "--port":
case "--dports", "--dport":
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])
}
i += 2
@ -2184,7 +2293,9 @@ func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) e
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 {
return nil
}
@ -2203,7 +2314,7 @@ func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) e
updated = append(updated, raw)
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) {
removed = true
continue
@ -2537,8 +2648,13 @@ func (f *IPTables) ipsetParseType(fields []string) (Family, SetType) {
func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
out, err := runCommand(ctx, "ipset", "save")
if err != nil {
// ipset not installed, or no sets: nothing to report.
return nil, nil
// 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, err
}
sets := map[string]*AddressSet{}
var names []string
@ -2552,7 +2668,9 @@ func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
}
for _, line := range out {
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 {
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
// chains and preserves every other nat chain verbatim — including OUTPUT, whose
// locally-generated DNAT the NATRule model cannot represent distinctly (see
// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING.
func (f *IPTables) managedNATChain(chain string) bool {
switch chain {
case "PREROUTING", "POSTROUTING":
@ -2849,19 +2968,51 @@ func (f *IPTables) managedNATChain(chain string) bool {
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
// the managed chains are exactly natLines, leaving the chain-policy lines, any
// user-defined nat chain, the *filter table and all other content untouched. A
// file with no *nat table gains one. It is the nat counterpart of
// user-defined nat chain, unmodeled managed-chain lines, the *filter table and
// 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 {
lines, err := f.readAllLines(path)
if err != nil {
return err
}
preserved := f.preservedNATLines(lines)
out := make([]string, 0, len(lines)+len(natLines))
inNat := false
inserted := false
for _, line := range lines {
for idx, line := range lines {
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(t, "*"):
@ -2876,9 +3027,10 @@ func (f *IPTables) rewriteNATRules(path string, natLines []string) error {
inNat = false
case inNat && strings.HasPrefix(f.ruleLineBody(t), "-A "):
// Drop an existing rule in a managed chain — the desired set replaces it —
// but preserve a rule in a user-defined nat chain verbatim, mirroring how
// rewriteFilterRules preserves FORWARD/custom-chain rules.
if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) {
// but preserve a rule in a user-defined nat chain, and an unmodeled
// managed-chain line, verbatim, mirroring how rewriteFilterRules
// preserves FORWARD/custom-chain rules and unmodeled filter lines.
if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) || preserved[idx] {
out = append(out, line)
}
default:
@ -3154,7 +3306,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
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)
writtenRule := false
filterFound := false
@ -3165,7 +3317,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
writtenRule = true
}
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())
// 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.
if !writtenRule {
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

View file

@ -818,7 +818,9 @@ func TestIPTablesMoveForeignChainPosition(t *testing.T) {
// TestIPTablesLogicalStarts checks that logicalStarts numbers physical `-A`
// 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
// 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) {
f := new(IPTables)
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}},
{"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}},
{"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 {
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})
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 (
"context"
"errors"
"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).
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
// First check for firewalld as there are no firewall managers that use it.
firewalld, err := NewFirewallD(ctx, rulePrefix)
if err == nil {
return firewalld, nil
// A probe error usually means "not installed", but it can also be a
// 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) }},
}
// Check if Ubuntu's Uncomplicated Firewall (UFW) is installed.
ufw, err := NewUFW(ctx, rulePrefix)
if err == nil {
return ufw, nil
for _, p := range probes {
mgr, err := p.try()
if err == nil {
return mgr, nil
}
errs = append(errs, fmt.Errorf("%s: %w", p.name, err))
}
// 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")
return nil, fmt.Errorf("no firewall manager found: %w", errors.Join(errs...))
}

View file

@ -5,12 +5,12 @@ import (
"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) {
// Attempt to connect to WFP.
wf, err := NewWF(ctx, rulePrefix)
if err == nil {
return wf, nil
}
return nil, fmt.Errorf("no firewall manager found")
return nil, fmt.Errorf("no firewall manager found: %w", err)
}

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).
{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}},
// 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, 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.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) {
@ -740,3 +739,155 @@ func TestNFTNATRejectsMergedProtocol(t *testing.T) {
_, _, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCPUDP, Port: 80, ToAddress: "192.0.2.1"})
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")
}

155
pf.go
View file

@ -70,18 +70,22 @@ func (f *PF) Capabilities() Capabilities {
// it lists the ruleset, so a discrete multi-port rule does not round-trip as
// a single rule (a range, kept as one token, does). Report PortList as
// unsupported to reflect that; callers open several ports with a rule each.
PortList: false,
ConnState: false,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: true,
DefaultPolicy: false,
RuleCounters: true,
AddressSets: true,
Comments: true,
PortList: false,
PortPair: true,
ConnState: false,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: true,
DefaultPolicy: false,
RuleCounters: true,
AddressSets: 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
// 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 len(rules) > 0 {
if len(rules) > 0 && rules[len(rules)-1] != nil {
if p, b, ok := f.parseRuleCounters(line); ok {
rules[len(rules)-1].Packets = p
rules[len(rules)-1].Bytes = b
@ -977,6 +983,10 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
}
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++ {
switch tokens[i] {
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])
}
case "from":
srcScope = true
val, neg, next, err := f.parseAddr(tokens, i+1)
if err != nil {
return nil, err
@ -1014,6 +1025,7 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
r.Source = neg + f.stripTable(val)
}
case "to":
srcScope = false
val, neg, next, err := f.parseAddr(tokens, i+1)
if err != nil {
return nil, err
@ -1023,6 +1035,9 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
r.Destination = neg + f.stripTable(val)
}
case "port":
if srcScope {
return nil, fmt.Errorf("source port match is not modeled")
}
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing port value")
@ -1050,8 +1065,13 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
}
target := tokens[i]
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
if i+1 < len(tokens) && tokens[i+1] == "port" {
return nil, fmt.Errorf("nat target port pool is not modeled")
}
} else {
r.ToAddress = target
// An optional `port N` gives the translation port. pfctl prints a
@ -1223,6 +1243,27 @@ var pfFilterKeywords = map[string]bool{
"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
// nat/rdr (translation) rules to precede filter rules in the ruleset, so they
// 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
}
// Rebuild the filter ruleset without the matching rule(s). GetRules collapses an
// IPv4/IPv6 twin into one FamilyAny rule, so removing that read-back rule must
// clear both underlying anchor rows; match with EqualForRemoval so a concrete-
// family target still removes only its own family and never the twin's row.
// Rebuild the filter ruleset without the matching rule(s). A FamilyAny target
// must clear every row it covers — an unpinned dual-family row and any
// family-pinned twins added one at a time — so match with EqualForRemoval; a
// concrete-family target still removes only its own family, never the twin's row.
kept := make([]string, 0, len(filterRaw))
removed := false
var reAdd *Rule
@ -1656,11 +1697,10 @@ func (f *PF) translationBoundary(data []string) int {
return len(data)
}
// ensureNATAnchors inserts them at that boundary. pf.conf sections are strictly
// ordered options → normalization → queueing → translation → filtering, so the
// queueing keywords (altq/queue) are deliberately NOT included: they precede the
// translation section, and treating one as the boundary would splice the nat/rdr
// anchors ahead of the queueing statements, which pfctl -f rejects.
// ensureNATAnchors makes sure pf.conf carries the nat-anchor and rdr-anchor
// references for our anchor, inserting any missing ones at the translation
// boundary (see translationBoundary), and ensures the filter anchor first via
// ensureAnchor.
func (f *PF) ensureNATAnchors(ctx context.Context) error {
if f.natEnsured {
return nil
@ -1861,13 +1901,24 @@ func (f *PF) isMissingTableErr(err error) bool {
return err != nil && strings.Contains(err.Error(), "does not exist")
}
// tableFamily infers a table's family from its entries (defaulting to IPv4).
// 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 {
v4, v6 := false, false
for _, e := range entries {
if strings.Contains(e, ":") {
return IPv6
v6 = true
} else {
v4 = true
}
}
switch {
case v4 && v6:
return FamilyAny
case v6:
return IPv6
}
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) {
out, err := runCommand(ctx, "pfctl", "-s", "Tables")
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
for _, line := range out {
@ -1905,7 +1959,10 @@ func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
continue
}
set, err := f.getAddressSet(ctx, name)
if err != nil || set == nil {
if err != nil {
return nil, err
}
if set == nil {
continue
}
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 {
return err
}
if len(set.Entries) == 0 {
// pf does not lazily create tables, so a later filter rule referencing
// <name> would fail to load. `-T replace` with no addresses creates the
// table empty (or empties an existing one) so it exists for the caller.
_, err := runCommand(ctx, "pfctl", "-t", set.Name, "-T", "replace")
return err
}
args := []string{"-t", set.Name, "-T", "add"}
// `-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
// <name> would otherwise fail to load.
args := []string{"-t", set.Name, "-T", "replace"}
args = append(args, set.Entries...)
_, err := runCommand(ctx, "pfctl", args...)
return err
@ -2031,13 +2085,11 @@ func (f *PF) Restore(ctx context.Context, zoneName string, backup *Backup) error
return err
}
filterLines := make([]string, 0, len(backup.Rules))
for _, r := range backup.Rules {
line, err := f.MarshalRule(r)
if err != nil {
return err
}
filterLines = append(filterLines, line)
// A portable backup may carry DirAny or TCPUDP rules captured from a backend
// that stores them as one row; expand exactly as the add paths do.
filterLines, err := f.marshalExpanded(backup.Rules)
if err != nil {
return err
}
natLines := make([]string, 0, len(backup.NATRules))
@ -2120,20 +2172,9 @@ func (f *PF) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Ru
return err
}
var filterRaw []string
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 {
return err
}
filterRaw = append(filterRaw, line)
}
}
filterRaw, err := f.marshalExpanded(rules)
if err != nil {
return err
}
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, "!", 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"
"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
@ -27,9 +27,11 @@ func commandExists(name string) bool {
}
// systemdUnitEnabled reports whether systemd reports name's unit as set to
// start at boot. "enabled", "enabled-runtime", and "generated" all count;
// "generated" is how systemd wraps a SysV init.d script that was registered to
// start. Returns false when systemd is not running or the unit is absent.
// start at boot. Only "enabled" and "enabled-runtime" count: a "generated"
// unit wraps a SysV init.d script and exists for every script regardless of
// 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 {
conn, err := dbus.NewWithContext(ctx)
if err != nil {
@ -41,7 +43,7 @@ func systemdUnitEnabled(ctx context.Context, name string) bool {
return false
}
switch prop.Value.Value() {
case "enabled", "enabled-runtime", "generated":
case "enabled", "enabled-runtime":
return true
}
return false
@ -120,7 +122,9 @@ func openrcOn(name string) bool {
// rcLocalOn reports whether name is invoked from an rc.local file, apf's
// last-resort enablement when neither systemd nor an init.d registration
// applies. Commented lines are ignored.
// 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 {
for _, p := range []string{"/etc/rc.local", "/etc/rc.d/rc.local"} {
data, err := os.ReadFile(p)
@ -132,8 +136,10 @@ func rcLocalOn(name string) bool {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.Contains(line, name) {
return true
for _, tok := range strings.Fields(line) {
if tok == name || strings.HasSuffix(tok, "/"+name) {
return true
}
}
}
}
@ -200,10 +206,17 @@ func enableService(ctx context.Context, name string) error {
return nil
}
switch state {
case "enabled", "enabled-runtime", "static", "generated":
// static units have no install info and are pulled in by other
case "enabled", "enabled-runtime", "static":
// Static units have no install info and are pulled in by other
// units, so there is nothing to enable.
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 {
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 {
if systemdActive() {
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 nil

View file

@ -21,11 +21,12 @@ const (
// UFWDefaults is ufw's defaults file, where the default-policy keys
// (DEFAULT_INPUT_POLICY, ...) actually live — not ufw.conf.
UFWDefaults = "/etc/default/ufw"
// The iptables rules files hold rules that run before/after the user rules,
// in iptables-restore format with ufw's own chains. They are the raw-iptables
// fallback for what ufw's tuple format cannot express — ICMP, SCTP, state
// matches, custom log prefixes, rate/connection limits, and NAT (written into
// before.rules' nat table via natHelper).
// UFWBefore and its peers are the iptables rules files holding rules that run
// before/after the user rules, in iptables-restore format with ufw's own
// chains. They are the raw-iptables fallback for what ufw's tuple format
// cannot express — ICMP, SCTP, state matches, custom log prefixes,
// rate/connection limits, and NAT (written into before.rules' nat table via
// natHelper).
UFWBefore = "/etc/ufw/before.rules"
UFWBefore6 = "/etc/ufw/before6.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.
fd, err := os.Open(UFWConf)
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.
@ -126,21 +127,25 @@ func (f *UFW) Type() string {
// Capabilities reports the features this backend supports.
func (f *UFW) Capabilities() Capabilities {
return Capabilities{
Output: true,
Forward: true,
ICMPv6: true,
PortList: true,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: true,
DefaultPolicy: true,
RuleCounters: false,
AddressSets: true,
Comments: true,
Output: true,
Forward: true,
ICMPv6: true,
PortList: true,
PortPair: true,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: true,
DefaultPolicy: true,
RuleCounters: false,
AddressSets: 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
// 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.
// An 8-token tuple never occurs in a real ufw file (ufw always writes both dapp
// and sapp, using "-" for whichever is absent) and is rejected as malformed.
// An 8-token tuple is ufw's old storage format (six core fields plus dapp/sapp
// 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) {
r = &Rule{
Family: family,
@ -271,9 +277,10 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
}
n := len(tokens)
// A route rule binding both interfaces adds a second trailing interface token,
// so it alone may reach eight tokens; every other tuple with eight is malformed.
if n < 6 || n > 9 || (n == 8 && !forward) {
// A dual-interface route rule stays at seven tokens — ufw joins both
// interfaces into one trailing "!"-separated token (`in_eth0!out_eth1`) — so
// eight tokens only occurs in ufw's unmodeled old format.
if n < 6 || n > 9 || n == 8 {
return nil, fmt.Errorf("invalid rule length")
}
@ -309,42 +316,48 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
return nil, fmt.Errorf("unsupported action: %s", tokens[0])
}
// The trailing token(s) after the six core fields carry the direction and any
// interface binding. An ordinary rule has one such token (`in`, `out`, or an
// interface-bound `in_eth0`/`out_eth0`); a route rule binding both interfaces
// has two (`in_eth0 out_eth1`). An application-profile tuple (n==9) carries the
// dapp/sapp labels in tokens 6-7 and the direction in token 8. A 6-token tuple
// omits the field and defaults to inbound. For a route rule the direction stays
// forward and the interfaces populate InInterface/OutInterface; for an ordinary
// rule the single token fixes the in/out direction and its interface.
var dirToks []string
// The trailing token after the six core fields carries the direction and any
// interface binding: `in`, `out`, an interface-bound `in_eth0`/`out_eth0`, or
// — on a route rule binding both interfaces — ufw's "!"-joined pair
// `in_eth0!out_eth1`, still one token. An application-profile tuple (n==9)
// carries the dapp/sapp labels in tokens 6-7 and the direction in token 8. A
// 6-token tuple omits the field and defaults to inbound. For a route rule the
// direction stays forward and the interfaces populate InInterface/
// OutInterface; for an ordinary rule the token fixes the in/out direction and
// its interface.
var dirTok string
switch n {
case 7:
dirToks = tokens[6:7]
case 8:
dirToks = tokens[6:8]
dirTok = tokens[6]
case 9:
dirToks = tokens[8:9]
dirTok = tokens[8]
}
for _, tok := range dirToks {
name, iface, hasIface := strings.Cut(tok, "_")
switch name {
case "in":
if !forward {
r.Direction = DirInput
if dirTok != "" {
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 {
case "in":
if !forward {
r.Direction = DirInput
}
if hasIface {
r.InInterface = iface
}
case "out":
if !forward {
r.Direction = DirOutput
}
if hasIface {
r.OutInterface = iface
}
default:
return nil, fmt.Errorf("unsupported direction: %s", dirTok)
}
if hasIface {
r.InInterface = iface
}
case "out":
if !forward {
r.Direction = DirOutput
}
if hasIface {
r.OutInterface = iface
}
default:
return nil, fmt.Errorf("unsupported direction: %s", tok)
}
}
@ -449,8 +462,9 @@ func (f *UFW) parseTupleRows(filePath string, family Family) ([]*Rule, error) {
continue
}
// Parse rule. A tuple this backend cannot model (e.g. a route/forward rule)
// is kept as a nil row so it still occupies a physical position.
// Parse rule. A tuple this backend cannot model (ufw's old 8-field
// 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)
if err != 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")
}
// Work on a copy as we infer the family and normalize the destination
// below, and we do not want to mutate the caller's rule.
// Work on a copy: the family is inferred and the destination normalized
// below, and the caller's rule must not be mutated.
ruleCopy := *r
r = &ruleCopy
@ -979,15 +993,25 @@ func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove
if err != nil {
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
inFilter := false
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
break
}
}
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 = append(out, lines[:commitIdx]...)
@ -1141,9 +1165,10 @@ func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error {
return err
}
args := f.ruleArgs(r, []string{"prepend"}, rule)
// Attach a comment: a user-supplied Comment takes precedence, otherwise the
// rule is tagged with our prefix so it can be identified as ours. The comment
// is only added on insert; ufw matches deletes on the rule without it.
// Attach a comment: the prefix tag and any user-supplied Comment are combined
// (prefix first, see combineComment) so the rule is identifiable as ours and
// 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 != "" {
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
// native insert position, given the physical tuple rows in ufw's own order. A nil
// row is a tuple ufw counts in its numbered list but this backend does not model (a
// route/forward rule); it still occupies a physical slot, so a route rule preceding
// the anchor shifts the native position instead of being ignored — which would
// place the rule one slot too early per preceding route rule and, for a first-match
// firewall, change enforcement. Every modeled tuple is its own rule, so with no
// row is a tuple ufw counts in its numbered list but this backend does not model
// (an old-format or otherwise unparseable tuple); it still occupies a physical
// slot, so such a row preceding the anchor shifts the native position instead of
// being ignored — which would place the rule one slot too early per preceding
// 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.
func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) 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
// reports it) to the 1-based position ufw's own numbered list uses for `ufw insert`.
// The two index spaces differ because ufw counts route rules this backend does not
// model. The tuple order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's
// The two index spaces differ because ufw's list also counts tuples this backend
// does not model. The tuple order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's
// native order, so the logical position's row in that list is its native position. A
// position past the last logical rule maps past the native count, which ufw rejects
// and InsertRule then handles as a plain append.
@ -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
// backend models, while `ufw insert` counts ufw's own numbered list — which also
// counts the route rules it does not model. Map to that native position so a
// route rule earlier in the list does not skew the insert.
// backend models, while `ufw insert` counts ufw's own numbered list — which
// also counts tuples it does not model. Map to that native position so an
// unmodeled tuple earlier in the list does not skew the insert.
native, err := f.nativeInsertPosition(position)
if err != nil {
return err
@ -1355,37 +1380,41 @@ func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return f.editIPTablesRules(r, true)
}
// Protocol-axis removal. A TCPUDP rule is backed either by a single native
// `any`-proto tuple or by a separately-added tcp tuple + udp tuple, so resolve the
// actual backing before deleting.
// Protocol-axis removal. A TCPUDP rule may be backed by a single native
// `any`-proto tuple, by a separately-added tcp tuple + udp tuple, or — since
// 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) {
native, err := f.storedNativeTCPUDP(r)
if err != nil {
return err
}
if native != nil {
// Backed by a single native `any` tuple. Delete that tuple via its bare/`any`
// form (a TCPUDP marshal of the target). When the target names a single
// transport, re-add the surviving opposite transport so its coverage is kept —
// the protocol analog of splitting a dual-family row on removal
// (splitDualRowProtocol returns nil for a TCPUDP target, dropping the whole row).
// Delete the native `any` tuple via its bare/`any` form (a TCPUDP
// marshal of the target).
del := *r
del.Proto = TCPUDP
if err := f.deleteNative(ctx, &del); err != nil {
return err
}
if s := splitDualRowProtocol(native, r); s != nil {
return f.AddRule(ctx, zoneName, s)
}
return nil
}
// No native tuple: the rule is backed by concrete-transport tuples. Expand a
// TCPUDP target into tcp+udp and delete each; a concrete target deletes itself.
// Delete the concrete-transport tuples: a TCPUDP target expands into
// tcp+udp, a concrete target deletes itself. deleteNative is a no-op for a
// tuple ufw does not hold.
for _, sub := range expandProtocols(r) {
if err := f.deleteNative(ctx, sub); err != nil {
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
}

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: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 {
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, 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, "\"'")
}
// 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,
// 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
// each backend's own EditConf/EditRulePort scan handles in place).
func readConfValue(path, key string) (string, error) {
@ -25,13 +45,10 @@ func readConfValue(path, key string) (string, error) {
return "", err
}
defer func() { _ = fd.Close() }()
value := ""
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := scanner.Text()
if ci := strings.IndexByte(line, '#'); ci >= 0 {
line = line[:ci]
}
line = strings.TrimSpace(line)
line := strings.TrimSpace(stripUnquotedComment(scanner.Text()))
if line == "" {
continue
}
@ -40,10 +57,10 @@ func readConfValue(path, key string) (string, error) {
continue
}
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
@ -146,9 +163,14 @@ func runCommandStdin(ctx context.Context, stdin string, command string, args ...
// Wait for the command to finish.
err = cmd.Wait()
if err != nil {
stderr := strings.TrimSpace(stderrData.String())
if stderr != "" {
err = fmt.Errorf("%s (stdout %s)", stderr, strings.Join(out, "\n"))
// Keep the underlying error wrapped so its exit code stays reachable
// through errors.As, and only mention stdout when there is any.
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
// read error is never hidden behind the command's own failure.

View file

@ -60,20 +60,27 @@ func (f *WF) Type() string {
// Capabilities returns the set of features the Windows firewall backend can express.
func (f *WF) Capabilities() Capabilities {
return Capabilities{
Output: true,
ICMPv6: true,
PortList: true,
ConnState: false,
InterfaceMatch: false,
Logging: false,
RateLimit: false,
ConnLimit: false,
NAT: false,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: false,
Comments: true,
Output: true,
ICMPv6: 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,
InterfaceMatch: false,
Logging: false,
RateLimit: false,
ConnLimit: false,
NAT: false,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: false,
Comments: true,
}
}
@ -198,11 +205,11 @@ func (f *WF) decodeAddress(addr string) (newAddr string, err error) {
newAddr = ip.String()
}
// Returned the parsed address.
// Return the parsed address.
return
}
// Handle single IP
// Handle a single IP.
ip := net.ParseIP(addr)
if ip == nil {
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") {
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.
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.
// Our rule uses source/destination where as windows uses local/remote.
// Based on direction, map the source and destination address: the rule model
// uses source/destination, whereas Windows uses local/remote.
var srcRaw, dstRaw string
if r.IsOutput() {
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
// 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) {
switch {
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
// 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 {
if !useFilter {
return true
@ -472,11 +498,17 @@ func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
if r.IsForward() {
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
// protocol; dropping the port would silently widen the rule to match all
// traffic, so reject it instead.
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
// (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, " ")
// Set the grouping
// Set the grouping.
fwRule.Grouping = f.rulePrefix
return fwRule, nil
}
// AddRule that stored the rule under the all-profiles default). A rule matches only
// when its Profiles exactly equals this single bit: AddRule always stores a named
// zone's rule under exactly one profile bit or the all-profiles default (never a
// combination), so an exact-equality test is what keeps a specific zone's rules
// disjoint from another zone's and from an all-profiles rule. Testing overlap
// instead (fr.Profiles&profile != 0) would let a single-zone query and, worse, a
// single-zone RemoveRule/Sync match and delete an all-profiles rule — silently
// affecting every other zone too.
// 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. WFP is
// the only such backend, so the helper lives here beside its caller.
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
}
// 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 {
if err := ctx.Err(); err != nil {
return err
@ -801,11 +845,19 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
// zoneName names a single zone.
filterProfile, useFilter := f.profileFilter(zoneName)
// Delete every matching rule. 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.
// Windows deletes by name and removes the FIRST rule bearing it, so a name
// shared with a rule this removal did not match (a per-profile twin of a
// built-in rule, say) could destroy the wrong rule. Count each name's holders
// 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 {
nameTotal[fr.Name]++
// Skip rules outside the target profile, matching GetRules' filter.
if !f.profileMatches(fr.Profiles, filterProfile, useFilter) {
continue
@ -817,14 +869,25 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
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) {
ok, err := wapi.FirewallRuleDelete(fr.Name)
if err != nil {
return fmt.Errorf("failed to delete rule %q: %w", fr.Name, err)
}
if !ok {
return fmt.Errorf("failed to delete rule %q: reported failure", 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 {
return fmt.Errorf("failed to delete rule %q: %w", t.name, err)
}
if !ok {
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())
}
// 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) {
rules, err := f.GetRules(ctx, zoneName)
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)
}