From 5095d90fa46dbd58576e217ac9e7e3579efbd30d Mon Sep 17 00:00:00 2001 From: James Coleman Date: Mon, 13 Jul 2026 17:50:43 -0500 Subject: [PATCH] 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. --- Makefile | 2 +- README.md | 12 +- apf_linux.go | 267 +++++++----- apf_linux_test.go | 85 +++- backup.go | 27 +- cmd/go-firewall/go.mod | 7 +- configfile.go | 21 +- configfile_test.go | 25 ++ csf_linux.go | 405 ++++++++++++------ csf_linux_test.go | 173 +++++++- firewall.go | 166 +++++--- firewall_test.go | 42 ++ firewalld_linux.go | 339 +++++++++------ firewalld_linux_test.go | 52 ++- go.mod | 4 +- hooks_linux.go | 494 +++++++++++++++++----- hooks_linux_test.go | 400 ++++++++++++++---- integration_linux_test.go | 261 ++++++++++++ integration_nohook_test.go | 32 ++ integration_test.go | 829 +++++++++++++++++++++++------------- iptables_linux.go | 306 ++++++++++---- iptables_linux_test.go | 127 +++++- manager_linux.go | 66 ++- manager_windows.go | 6 +- nft_linux.go | 844 ++++++++++++++++++++++++++++--------- nft_linux_test.go | 159 ++++++- pf.go | 155 ++++--- pf_test.go | 86 ++++ services.go | 42 +- ufw_linux.go | 209 +++++---- ufw_linux_test.go | 46 +- utils.go | 44 +- wf_windows.go | 146 +++++-- wf_windows_test.go | 28 ++ 34 files changed, 4448 insertions(+), 1459 deletions(-) diff --git a/Makefile b/Makefile index 7ea8001..9bb40aa 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index d9beeca..8621d17 100644 --- a/README.md +++ b/README.md @@ -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} diff --git a/apf_linux.go b/apf_linux.go index a92b93e..3e5c470 100644 --- a/apf_linux.go +++ b/apf_linux.go @@ -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 } diff --git a/apf_linux_test.go b/apf_linux_test.go index e934a41..171707d 100644 --- a/apf_linux_test.go +++ b/apf_linux_test.go @@ -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") +} diff --git a/backup.go b/backup.go index 97c8f83..fe38320 100644 --- a/backup.go +++ b/backup.go @@ -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 diff --git a/cmd/go-firewall/go.mod b/cmd/go-firewall/go.mod index e7f7f83..a1aff32 100644 --- a/cmd/go-firewall/go.mod +++ b/cmd/go-firewall/go.mod @@ -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 diff --git a/configfile.go b/configfile.go index 7b04fba..bbc0162 100644 --- a/configfile.go +++ b/configfile.go @@ -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 diff --git a/configfile_test.go b/configfile_test.go index e603036..4bce4b1 100644 --- a/configfile_test.go +++ b/configfile_test.go @@ -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) +} diff --git a/csf_linux.go b/csf_linux.go index 283b175..d91fcf2 100644 --- a/csf_linux.go +++ b/csf_linux.go @@ -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 `), 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 `), 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 diff --git a/csf_linux_test.go b/csf_linux_test.go index 9bbadfa..3336e33 100644 --- a/csf_linux_test.go +++ b/csf_linux_test.go @@ -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") +} diff --git a/firewall.go b/firewall.go index bf26ed9..7c7030f 100644 --- a/firewall.go +++ b/firewall.go @@ -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 } diff --git a/firewall_test.go b/firewall_test.go index 4ee7b09..7927575 100644 --- a/firewall_test.go +++ b/firewall_test.go @@ -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) +} diff --git a/firewalld_linux.go b/firewalld_linux.go index 878a5d4..9f38fdf 100644 --- a/firewalld_linux.go +++ b/firewalld_linux.go @@ -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:). +// 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:) 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 } diff --git a/firewalld_linux_test.go b/firewalld_linux_test.go index b08fd9e..d7fb052 100644 --- a/firewalld_linux_test.go +++ b/firewalld_linux_test.go @@ -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"})) +} diff --git a/go.mod b/go.mod index ce77e92..5c9a57e 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/hooks_linux.go b/hooks_linux.go index a845ce8..a75abe7 100644 --- a/hooks_linux.go +++ b/hooks_linux.go @@ -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 diff --git a/hooks_linux_test.go b/hooks_linux_test.go index b823cec..92c9730 100644 --- a/hooks_linux_test.go +++ b/hooks_linux_test.go @@ -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) +} diff --git a/integration_linux_test.go b/integration_linux_test.go index 6f70c51..885eab4 100644 --- a/integration_linux_test.go +++ b/integration_linux_test.go @@ -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 +} diff --git a/integration_nohook_test.go b/integration_nohook_test.go index 2511987..1248574 100644 --- a/integration_nohook_test.go +++ b/integration_nohook_test.go @@ -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 +} diff --git a/integration_test.go b/integration_test.go index a272c8c..6b79989 100644 --- a/integration_test.go +++ b/integration_test.go @@ -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 diff --git a/iptables_linux.go b/iptables_linux.go index 823b34f..c33d6a7 100644 --- a/iptables_linux.go +++ b/iptables_linux.go @@ -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 `), 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 diff --git a/iptables_linux_test.go b/iptables_linux_test.go index 2749e9e..27bff74 100644 --- a/iptables_linux_test.go +++ b/iptables_linux_test.go @@ -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") +} diff --git a/manager_linux.go b/manager_linux.go index 6a20f32..36bb1fd 100644 --- a/manager_linux.go +++ b/manager_linux.go @@ -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...)) } diff --git a/manager_windows.go b/manager_windows.go index cf170c3..a542037 100644 --- a/manager_windows.go +++ b/manager_windows.go @@ -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) } diff --git a/nft_linux.go b/nft_linux.go index f15f8ef..9eee705 100644 --- a/nft_linux.go +++ b/nft_linux.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "hash/fnv" "net" "strconv" "strings" @@ -26,7 +27,7 @@ type NFT struct { // the one-time table/chain setup. mu sync.Mutex // ensured records whether the private table/chains have been created this - // session so we only run the setup commands once. + // session, so the setup commands run only once. ensured bool // natEnsured records the same for the nat base chains, which are created // lazily only when a NAT rule is first written. @@ -50,6 +51,11 @@ func sanitizeNFTName(prefix string) string { if name == "" { return NFTDefaultTable } + // An nftables identifier must begin with a letter; a digit-led prefix would + // make every later `nft add` fail with a raw syntax error. + if c := name[0]; c >= '0' && c <= '9' { + name = "fw_" + name + } return name } @@ -78,25 +84,29 @@ func (f *NFT) Type() string { // Capabilities returns the set of features this backend can express. func (f *NFT) 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, } } -// GetZone reports no zone; nftables has no interface-to-zone mapping in the model we expose. +// GetZone reports no zone; nftables has no interface-to-zone mapping in this model. func (f *NFT) GetZone(ctx context.Context, iface string) (zoneName string, err error) { return "", nil } @@ -160,34 +170,37 @@ func (f *NFT) unquote(s string) string { return trimQuotes(s) } -// joinQuoted reassembles a double-quoted value that strings.Fields split on -// its internal whitespace. Given the index of the token that begins the value, -// it returns the unquoted value and the index of the final token consumed. When -// the token at start is not a quoted string it returns that single token -// unquoted. It mirrors the reassembly the comment parser does, so a spaced value -// (a log prefix like "FW DROP: ") round-trips instead of truncating at the space. -func (f *NFT) joinQuoted(tokens []string, start int) (string, int) { - if start >= len(tokens) { - return "", start - } - if !strings.HasPrefix(tokens[start], "\"") { - return f.unquote(tokens[start]), start - } - var parts []string - j := start - for ; j < len(tokens); j++ { - parts = append(parts, tokens[j]) - joined := strings.Join(parts, " ") - // nft's quoting has no escape mechanism, so the first closing quote - // terminates the value. - if len(joined) >= 2 && strings.HasSuffix(joined, "\"") { - break +// splitQuoted splits a rule line into whitespace-separated tokens, keeping a +// double-quoted string (a comment, a log prefix) as one token with its quotes +// and interior spacing intact. strings.Fields would split it and a single-space +// rejoin would collapse consecutive spaces — but LogPrefix is part of rule +// identity, so a collapsed read-back would never match its own rule and Sync +// would re-add a duplicate row each pass. nft's quoting has no escape mechanism +// (a `"` unconditionally toggles the quoted state), so no escape tracking is +// needed. +func (f *NFT) splitQuoted(line string) []string { + var tokens []string + var b strings.Builder + inQuote := false + flush := func() { + if b.Len() > 0 { + tokens = append(tokens, b.String()) + b.Reset() } } - if j >= len(tokens) { - j = len(tokens) - 1 + for _, r := range line { + switch { + case r == '"': + inQuote = !inQuote + b.WriteRune(r) + case !inQuote && (r == ' ' || r == '\t'): + flush() + default: + b.WriteRune(r) + } } - return f.unquote(strings.Join(parts, " ")), j + flush() + return tokens } // parsePorts converts a list of nftables port members (e.g. "80", @@ -228,11 +241,9 @@ func (f *NFT) parseRate(tokens []string, i int) (*RateLimit, int, error) { i++ } // nftables applies a default burst of 5 packets to every limit statement - // and prints it back on `nft list` even when none was requested. A rule - // marshaled with Burst 0 (the documented "backend default") would otherwise - // read back as Burst 5 and fail rule-identity comparison, so treat nft's - // default as unset. An explicit Burst of 5 is indistinguishable from the - // default and equivalent to it. + // and prints it back on `nft list` even when none was requested, so treat + // nft's default as unset in the reported value. An explicit Burst of 5 is + // indistinguishable from the default and equivalent to it. if rl.Burst == 5 { rl.Burst = 0 } @@ -289,12 +300,16 @@ func (f *NFT) protoFromToken(tok string) Protocol { // l4ProtoFromToken decodes the value of a `meta l4proto` clause, which is either a // single protocol name (protoFromToken) or an anonymous set. The only set this // backend writes, and the only one the Rule model can represent, is the -// both-transports `{ tcp, udp }` of a TCPUDP rule; any other set belongs to a -// foreign rule whose protocol coverage a single Proto field cannot hold, so it is -// rejected rather than silently narrowed to one member or widened to ProtocolAny. +// both-transports `{ tcp, udp }` of a TCPUDP rule. Any other set — and any single +// protocol the model does not know (igmp, an unmapped number) — belongs to a +// foreign rule whose coverage a single Proto field cannot hold, so it is rejected +// rather than silently narrowed to one member or widened to ProtocolAny. func (f *NFT) l4ProtoFromToken(tok string) (Protocol, error) { if !strings.HasPrefix(tok, "{") { - return f.protoFromToken(tok), nil + if p := f.protoFromToken(tok); p != ProtocolAny { + return p, nil + } + return ProtocolAny, fmt.Errorf("unsupported l4proto: %s", tok) } members := f.parseSetTokens(tok) if len(members) == 2 { @@ -318,7 +333,7 @@ func (f *NFT) stripSetRef(v string) string { func (f *NFT) UnmarshalRule(line string, chain string) (r *Rule, handle string, err error) { r = &Rule{Direction: f.directionForChain(chain)} - tokens := strings.Fields(f.collapseSetSpaces(line)) + tokens := f.splitQuoted(f.collapseSetSpaces(line)) for i := 0; i < len(tokens); i++ { switch tokens[i] { case "ip", "ip6": @@ -417,12 +432,12 @@ func (f *NFT) UnmarshalRule(line string, chain string) (r *Rule, handle string, i = next case "log": r.Log = true - // Optional `prefix "..."` and `level ` qualifiers follow. + // Optional `prefix "..."` and `level ` qualifiers follow. The + // prefix is a single splitQuoted token, spacing intact. for i+1 < len(tokens) { if tokens[i+1] == "prefix" && i+2 < len(tokens) { - // The prefix is a quoted string that strings.Fields may have - // split on its internal spaces; reassemble it fully. - r.LogPrefix, i = f.joinQuoted(tokens, i+2) + r.LogPrefix = f.unquote(tokens[i+2]) + i += 2 } else if tokens[i+1] == "level" && i+2 < len(tokens) { i += 2 } else { @@ -510,6 +525,30 @@ func (f *NFT) UnmarshalRule(line string, chain string) (r *Rule, handle string, return nil, "", fmt.Errorf("unsupported meta match") } i += 2 + case "add", "update", "meter": + // A per-source connection limit. nft >= 1.1 lists the meter statement + // as `add @name { ip saddr ct count over N }` (or `update`); 1.0.x + // echoes the written spelling back as `meter name size 65535 { ... }`, + // so the optional size qualifier is consumed too. collapseSetSpaces + // has already collapsed the braces' body into one blob token. The + // set's key pins the rule's family, and nft folds any redundant + // nfproto match away, so the family comes from the key here. + if i+2 >= len(tokens) { + return nil, "", fmt.Errorf("incomplete meter statement") + } + name := strings.TrimPrefix(tokens[i+1], "@") + body := i + 2 + if tokens[body] == "size" && body+2 < len(tokens) { + body += 2 + } + fam, count, ok := parseMeterBlob(tokens[body]) + if !ok { + return nil, "", fmt.Errorf("unsupported dynamic-set statement: %s", tokens[body]) + } + r.Family = fam + r.ConnLimit = &ConnLimit{Count: count, PerSource: true} + r.meterSet = name + i = body case "accept": r.Action = Accept case "drop": @@ -533,23 +572,11 @@ func (f *NFT) UnmarshalRule(line string, chain string) (r *Rule, handle string, } case "comment": // nft prints the rule comment as a double-quoted string, before an - // optional `# handle N` marker. Reassemble it by collecting tokens up - // to the closing quote, so a comment containing spaces — or a literal - // '#', which strings.Fields would otherwise mistake for the handle - // marker and truncate the rule at — round-trips intact. + // optional `# handle N` marker. splitQuoted keeps the quoted comment + // as one token — spaces, a literal '#', and interior spacing intact. if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "\"") { - var cparts []string - j := i + 1 - for ; j < len(tokens); j++ { - cparts = append(cparts, tokens[j]) - joined := strings.Join(cparts, " ") - // The first closing quote terminates the comment. - if len(joined) >= 2 && strings.HasSuffix(joined, "\"") { - break - } - } - r.Comment = f.unquote(strings.Join(cparts, " ")) - i = j + r.Comment = f.unquote(tokens[i+1]) + i++ } else { // Unquoted (a single bare word, or older nft): take tokens up to // the handle marker. @@ -662,7 +689,25 @@ func (f *NFT) listForeignRules(ctx context.Context) ([]*Rule, error) { return rules, nil } -// listChain returns every parsed rule (with its nftables handle) in a chain. +// lineHandle extracts the trailing `# handle N` marker nft -a appends to a rule +// line, so an unmodeled row still yields the handle its slot is tracked by. The +// marker is the line's final clause, so the scan runs from the end and cannot be +// fooled by the word "handle" inside a quoted comment. +func (f *NFT) lineHandle(line string) string { + fields := strings.Fields(line) + for i := len(fields) - 1; i > 0; i-- { + if fields[i-1] == "handle" { + return fields[i] + } + } + return "" +} + +// listChain returns the chain's rules with their nftables handles, 1:1 with its +// physical rows. A row the model cannot parse is kept as an opaque slot — a nil +// rule with its handle — so a rewrite deletes only rows the model understands +// and the position math stays aligned; GetRules and the dedup scans skip the +// nil entries. func (f *NFT) listChain(ctx context.Context, chain string) (rules []*Rule, handles []string, err error) { out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain) if err != nil { @@ -675,12 +720,18 @@ func (f *NFT) listChain(ctx context.Context, chain string) (rules []*Rule, handl for _, line := range out { line = strings.TrimSpace(line) - // Only rule lines carry a handle; skip table/chain scaffolding. - if line == "" || !strings.Contains(line, "handle ") { + // Only rule lines carry a handle; skip table/chain scaffolding (whose + // header lines carry `{ # handle N` markers of their own). + if line == "" || !strings.Contains(line, "handle ") || + strings.HasPrefix(line, "table ") || strings.HasPrefix(line, "chain ") { continue } rule, handle, perr := f.UnmarshalRule(line, chain) if perr != nil { + if h := f.lineHandle(line); h != "" { + rules = append(rules, nil) + handles = append(handles, h) + } continue } // Rules live in this backend's own table; membership in the library's @@ -709,7 +760,12 @@ func (f *NFT) listOwnRules(ctx context.Context) ([]*Rule, error) { if cerr != nil { return nil, cerr } - rules = append(rules, chainRules...) + // Opaque (nil) rows stay in the chain but are not reportable rules. + for _, r := range chainRules { + if r != nil { + rules = append(rules, r) + } + } } numberByDirection(rules) return rules, nil @@ -830,6 +886,14 @@ func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) { if err := f.checkQuotable(r.LogPrefix, "log prefix"); err != nil { return "", "", err } + // A named set is family-typed, so a family-agnostic set reference cannot be + // rendered: l3Match would silently pin it to IPv4 and the rule would never + // read back as the FamilyAny rule the caller keeps trying to add. The entry + // points pin such a rule to the set's own family first (resolveSetRefFamily), + // so reaching here unresolved is a caller bug. + if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) { + return "", "", fmt.Errorf("a set-referencing rule requires a concrete family; the caller must resolve the set's family first") + } chain = "input" switch r.Direction { @@ -861,8 +925,11 @@ func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) { // In an inet table the family is only implied when an address match carries // it (ip/ip6). For a family-specific rule with no address, pin the family - // explicitly so the rule does not silently widen to both families. - if r.Family != FamilyAny && r.Source == "" && r.Destination == "" { + // explicitly so the rule does not silently widen to both families. A + // per-source connection limit is excluded: its meter key (`ip saddr` / + // `ip6 saddr`) already pins the family, and nft folds the then-redundant + // nfproto match away on listing, so an emitted one would never round-trip. + if r.Family != FamilyAny && r.Source == "" && r.Destination == "" && !r.perSourceLimited() { nfproto := "ipv4" if r.Family == IPv6 { nfproto = "ipv6" @@ -926,13 +993,24 @@ func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) { } // Connection limit: `ct count over N` matches while the tracked connection - // count exceeds the limit. Per-source counting needs a named meter, which - // this model does not express. + // count exceeds the limit. Per-source counting keys the count on the source + // address in a named dynamic set (the meter statement auto-creates it); the + // set is family-typed, so a FamilyAny rule is fanned out into one row per + // family by insertRule/MoveRule/ReplaceRulesBatch before reaching here. if r.ConnLimit != nil { if r.ConnLimit.PerSource { - return "", "", fmt.Errorf("nftables per-source connection limiting requires a named meter, unsupported in this model") + fam := r.impliedFamily() + if fam == FamilyAny { + return "", "", fmt.Errorf("a per-source connection limit counts in a family-typed meter; the caller must expand the rule to concrete families first") + } + key := "ip" + if fam == IPv6 { + key = "ip6" + } + parts = append(parts, fmt.Sprintf("meter %s { %s saddr ct count over %d }", f.meterName(chain, r), key, r.ConnLimit.Count)) + } else { + parts = append(parts, fmt.Sprintf("ct count over %d", r.ConnLimit.Count)) } - parts = append(parts, fmt.Sprintf("ct count over %d", r.ConnLimit.Count)) } // Logging, emitted just before the verdict so the packet is logged and then @@ -975,6 +1053,120 @@ func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) { return chain, strings.Join(parts, " "), nil } +// setRefFamily resolves the single family of the named address set(s) a rule +// references, the nft analog of the hook layer's setRefFamily: a named set is +// family-typed, so a family-agnostic set-referencing rule is pinned to the +// set's own family rather than rejected — its rows could never match the other +// family anyway. A set that does not exist, or a source/destination pair naming +// sets of different families, cannot produce a loadable rule, so both are +// errors. +func (f *NFT) setRefFamily(ctx context.Context, source, destination string) (Family, error) { + fam := FamilyAny + for _, ref := range []string{source, destination} { + if !isSetRef(ref) { + continue + } + _, bare := splitAddrNeg(strings.TrimSpace(ref)) + name := strings.TrimPrefix(bare, "@") + set, err := f.getAddressSet(ctx, name) + if err != nil { + return FamilyAny, err + } + if set == nil { + return FamilyAny, fmt.Errorf("rule references unknown address set %q", name) + } + sf := set.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 +} + +// resolveSetRefFamily returns r pinned to its referenced set's family when the +// rule is family-agnostic and names a set; every other rule passes through +// unchanged. Callers resolve before marshalling, so MarshalRule never sees an +// unpinned set reference. +func (f *NFT) resolveSetRefFamily(ctx context.Context, r *Rule) (*Rule, error) { + if r.impliedFamily() != FamilyAny || (!isSetRef(r.Source) && !isSetRef(r.Destination)) { + return r, nil + } + fam, err := f.setRefFamily(ctx, r.Source, r.Destination) + if err != nil { + return nil, err + } + rc := *r + rc.Family = fam + return &rc, nil +} + +// resolveNATSetRefFamily is resolveSetRefFamily for NAT rules. +func (f *NFT) resolveNATSetRefFamily(ctx context.Context, r *NATRule) (*NATRule, error) { + if r.impliedFamily() != FamilyAny || (!isSetRef(r.Source) && !isSetRef(r.Destination)) { + return r, nil + } + fam, err := f.setRefFamily(ctx, r.Source, r.Destination) + if err != nil { + return nil, err + } + rc := *r + rc.Family = fam + return &rc, nil +} + +// meterName derives the dynamic-set name a per-source connection-limit rule +// counts in. The name is a hash of the rule's identity (chain, family, match, +// limit and verdict) so two distinct per-source rules never share counting +// state, while a re-add — or a split's re-add — of the same rule deterministically +// reuses its set. The comment is excluded, as it is not part of rule identity. +func (f *NFT) meterName(chain string, r *Rule) string { + h := fnv.New64a() + _, _ = fmt.Fprintf(h, "%s|%d|%d|%s|%s|%d|%s|%s|%v|%d|%d|%d", + chain, r.impliedFamily(), r.Proto, r.Source, r.Destination, + r.State, FormatPortRanges(r.PortSpecs(), ","), FormatPortRanges(r.SourcePortSpecs(), ","), + r.Log, r.Action, r.ConnLimit.Count, r.Priority) + return fmt.Sprintf("cl%016x", h.Sum64()) +} + +// parseMeterBlob decodes the collapsed body of a per-source meter clause. The +// listing prints `add @name { ip saddr ct count over N }`, and collapseSetSpaces +// strips the braces' interior spacing before tokenizing, so the body arrives as +// one blob token like "{ipsaddrctcountoverN}". Only the source-keyed +// connection-count form is modeled; any other dynamic-set body fails, leaving +// the row opaque. +func parseMeterBlob(blob string) (fam Family, count uint, ok bool) { + blob = strings.TrimSuffix(strings.TrimPrefix(blob, "{"), "}") + fam = IPv4 + rest, found := strings.CutPrefix(blob, "ipsaddrctcountover") + if !found { + fam = IPv6 + rest, found = strings.CutPrefix(blob, "ip6saddrctcountover") + } + if !found { + return FamilyAny, 0, false + } + n, err := strconv.ParseUint(rest, 10, 32) + if err != nil { + return FamilyAny, 0, false + } + return fam, uint(n), true +} + +// perSourceFamilySplit reports whether a rule must be fanned out into one row +// per family before marshalling: a per-source connection limit counts in a +// family-typed dynamic set, so a FamilyAny rule has no single nftables row — +// the family analog of the DirAny fan-out. +func perSourceFamilySplit(r *Rule) bool { + return r.perSourceLimited() && r.impliedFamily() == FamilyAny +} + // ensureTable creates the private table and its input/output/forward base chains // if they do not already exist. `add` is idempotent in nftables, so re-running is // safe. @@ -1015,7 +1207,7 @@ var nftFilterChains = []string{"input", "output", "forward"} // index), but it has no insert-at-end form, so an index at or past the end must // append with `add rule`. func (f *NFT) placeArgs(chain, expr string, insPos, n int) []string { - fields := strings.Fields(expr) + fields := f.splitQuoted(expr) switch { case insPos >= n: return append([]string{"add", "rule", "inet", f.table, chain}, fields...) @@ -1027,15 +1219,33 @@ func (f *NFT) placeArgs(chain, expr string, insPos, n int) []string { } // ruleExists reports whether existing already contains a rule matching r. +// Opaque (nil) rows never match. func (f *NFT) ruleExists(existing []*Rule, r *Rule) bool { for _, e := range existing { - if e.EqualForDedup(r, true) { + if e != nil && e.EqualForDedup(r, true) { return true } } return false } +// physicalIndex maps a 1-based position over the modeled (non-nil) rows to the +// 0-based physical row index it lands at, keeping opaque rows pinned in place; +// a position past the last modeled row maps to the end of the chain. +func physicalIndex[T any](rows []*T, position int) int { + seen := 0 + for i, e := range rows { + if e == nil { + continue + } + seen++ + if seen == position { + return i + } + } + return len(rows) +} + func (f *NFT) insertRule(ctx context.Context, zoneName string, position int, r *Rule) error { if err := f.ensureTable(ctx); err != nil { return err @@ -1052,6 +1262,23 @@ func (f *NFT) insertRule(ctx context.Context, zoneName string, position int, r * return nil } + // A FamilyAny per-source connection limit has no single row (its meter set is + // family-typed): fan out into a v4 row and a v6 row, each with its own set. + if perSourceFamilySplit(r) { + for _, sub := range expandFamilies(r) { + if err := f.insertRule(ctx, zoneName, position, sub); err != nil { + return err + } + } + return nil + } + + // A family-agnostic set reference is pinned to the set's own family. + r, err := f.resolveSetRefFamily(ctx, r) + if err != nil { + return err + } + chain, expr, err := f.MarshalRule(r) if err != nil { return err @@ -1067,15 +1294,15 @@ func (f *NFT) insertRule(ctx context.Context, zoneName string, position int, r * } if position >= 1 { - // Each chain row is its own rule, so a Number is already a physical index; - // a position past the last row appends. - insPos := min(position-1, len(existing)) + // A position counts the modeled rows, matching GetRules' numbering; + // physicalIndex maps it past any opaque rows to the chain's real index. + insPos := physicalIndex(existing, position) args := f.placeArgs(chain, expr, insPos, len(existing)) _, err = runCommand(ctx, "nft", args...) return err } - args := append([]string{"add", "rule", "inet", f.table, chain}, strings.Fields(expr)...) + args := append([]string{"add", "rule", "inet", f.table, chain}, f.splitQuoted(expr)...) _, err = runCommand(ctx, "nft", args...) return err } @@ -1088,6 +1315,7 @@ func (f *NFT) AddRule(ctx context.Context, zoneName string, r *Rule) error { // InsertRule inserts rule before the given 1-based position. position <= 0 is // treated as 1 (prepend); a position larger than the current rule count appends // the rule. Normalizing here keeps insertRule's -1 sentinel reserved for +// AddRule's plain append. func (f *NFT) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { if position <= 0 { position = 1 @@ -1127,6 +1355,27 @@ func (f *NFT) MoveRule(ctx context.Context, zoneName string, r *Rule, position i return nil } + // A FamilyAny per-source connection limit occupies one row per family; move + // each to the requested position, mirroring the insertRule fan-out. + if perSourceFamilySplit(r) { + if err := f.ensureTable(ctx); err != nil { + return err + } + for _, sub := range expandFamilies(r) { + if err := f.MoveRule(ctx, zoneName, sub, position); err != nil { + return err + } + } + return nil + } + + // A family-agnostic set reference is pinned to the set's own family so the + // re-add below marshals; the rows it targets are already pinned on read. + r, rerr := f.resolveSetRefFamily(ctx, r) + if rerr != nil { + return rerr + } + chain := f.chainForDirection(r.Direction) if err := f.ensureTable(ctx); err != nil { @@ -1138,56 +1387,120 @@ func (f *NFT) MoveRule(ctx context.Context, zoneName string, r *Rule, position i return err } - // Collect every row this rule covers. A FamilyAny or TCPUDP target spans rows the - // chain may hold separately (an IPv4 row and an IPv6 row added one at a time), so - // moving it must relocate all of them; relocating only the first would orphan the - // rest — the same hazard RemoveRule guards against via EqualForRemoval. A - // concrete-family target still moves only its own family. - firstIdx := -1 - var toDelete []string - for i, e := range rules { - if e.EqualForRemoval(r, true) { - if firstIdx < 0 { - firstIdx = i - } - toDelete = append(toDelete, handles[i]) + // The target's current position is its first matching row's position among + // the modeled rows; moving it there is a no-op. + firstLogical := -1 + logical := 0 + for _, e := range rules { + if e == nil { + continue + } + logical++ + if firstLogical < 0 && e.EqualForRemoval(r, true) { + firstLogical = logical } } - if firstIdx < 0 { + if firstLogical < 0 { + return nil + } + if position == firstLogical { return nil } - // Each chain row is its own rule, so a position is a physical index. The target's - // current position is that of its first matching row. - if position > len(rules) { - position = len(rules) - } - // If moving to the same position, nothing to do. - if position == firstIdx+1 { - return nil - } - - // nft has no native move; delete every matching row, then re-insert once at the - // target position within the now-reduced chain. - for _, h := range toDelete { - if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", h); err != nil { - return err - } + // nft has no native move. Delete every row the target covers — a FamilyAny or + // TCPUDP target spans rows the chain may hold separately, so all of them + // relocate — but a concrete target that matched a merged row must not take + // the untargeted coverage with it: removeCovered re-adds each merged row's + // remainder in its own slot, and only the targeted rule moves. + virtual, _, err := f.removeCovered(ctx, chain, rules, handles, r) + if err != nil { + return err } _, expr, err := f.MarshalRule(r) if err != nil { return err } - // The rows were just deleted, so the chain now holds the remaining rows; clamp the - // target position to that reduced chain (appending when it is at or past the end). - remaining := len(rules) - len(toDelete) - insPos := min(position-1, remaining) - args := f.placeArgs(chain, expr, insPos, remaining) + insPos := physicalIndex(virtual, position) + args := f.placeArgs(chain, expr, insPos, len(virtual)) _, err = runCommand(ctx, "nft", args...) return err } +// removeCovered deletes every chain row the target covers and re-adds each +// merged row's untargeted remainder (see splitMergedRow) in the row's own slot, +// so coverage the caller never named survives in place. It returns the chain's +// resulting rows — opaque (nil) rows included, remainders in their slots — so a +// caller can place a follow-up insert, plus whether any row was deleted. +func (f *NFT) removeCovered(ctx context.Context, chain string, rules []*Rule, handles []string, r *Rule) ([]*Rule, bool, error) { + matched := make([]bool, len(rules)) + splits := make([][]*Rule, len(rules)) + var delHandles []string + for i, e := range rules { + if e == nil || !e.EqualForRemoval(r, true) { + continue + } + matched[i] = true + delHandles = append(delHandles, handles[i]) + // A concrete target that matched a multi-state row would drop coverage + // the caller never asked to remove: an unpinned inet row covers both + // families, and a `meta l4proto { tcp, udp }` row both transports. + splits[i] = splitMergedRow(e, r) + } + if len(delHandles) == 0 { + return rules, false, nil + } + + for _, h := range delHandles { + if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", h); err != nil { + return nil, false, err + } + } + + // Rebuild the chain's shape: unmatched rows keep their order, and each + // matched row's remainder rows take its slot. The virtual list's indexes are + // the physical insert positions when the inserts run in ascending order. + virtual := make([]*Rule, 0, len(rules)) + type pendingReAdd struct { + rule *Rule + at int + } + var reAdds []pendingReAdd + for i, e := range rules { + if !matched[i] { + virtual = append(virtual, e) + continue + } + for _, s := range splits[i] { + reAdds = append(reAdds, pendingReAdd{s, len(virtual)}) + virtual = append(virtual, s) + } + } + base := len(rules) - len(delHandles) + for n, ra := range reAdds { + _, expr, merr := f.MarshalRule(ra.rule) + if merr != nil { + return nil, false, merr + } + args := f.placeArgs(chain, expr, ra.at, base+n) + if _, err := runCommand(ctx, "nft", args...); err != nil { + return nil, false, err + } + } + + // A deleted per-source connection-limit row leaves its counting set behind; + // drop each removed row's meter set now that its rule is gone. The delete is + // best-effort: one that fails (a surviving row — foreign, or a remainder + // re-added above — still references the set) leaves the set in place, which + // is the correct outcome. + for i, e := range rules { + if matched[i] && e != nil && e.meterSet != "" { + _, _ = runCommand(ctx, "nft", "delete", "set", "inet", f.table, e.meterSet) + } + } + return virtual, true, nil +} + // RemoveRule removes a rule from the zone. func (f *NFT) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { if err := f.ensureTable(ctx); err != nil { @@ -1207,7 +1520,6 @@ func (f *NFT) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { chain := f.chainForDirection(r.Direction) - // Find the matching rule so we can delete it by its handle. rules, handles, err := f.listChain(ctx, chain) if err != nil { return err @@ -1215,33 +1527,10 @@ func (f *NFT) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { // Delete every row the target covers, not just the first: a FamilyAny target // clears both an unpinned row and any family-pinned rows it spans, and a TCPUDP // target clears both transports. A concrete-family target still removes only its - // own family — see EqualForRemoval. - var reAdd []*Rule - reAddPos := 0 - for i, e := range rules { - if e.EqualForRemoval(r, true) { - if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil { - return err - } - // A concrete target that matched a multi-state row would drop coverage the - // caller never asked to remove: an unpinned inet row covers both families, and - // a `meta l4proto { tcp, udp }` row both transports. Re-add the remainder at - // the row's own position so it keeps the removed rule's place in the chain. - if s := splitMergedRow(e, r); len(s) > 0 { - reAdd = s - reAddPos = i + 1 - } - } - } - // Re-add in order so the remainder rows keep the removed row's position; each - // insert shifts the next one down by a slot. - for n, s := range reAdd { - if err := f.insertRule(ctx, zoneName, reAddPos+n, s); err != nil { - return err - } - } - // Nothing left to remove. - return nil + // own family — see EqualForRemoval; removeCovered re-adds each merged row's + // untargeted remainder in the row's own slot. + _, _, err = f.removeCovered(ctx, chain, rules, handles, r) + return err } // parseNATTarget parses an nftables NAT target token ("addr", "addr:port", @@ -1292,7 +1581,7 @@ func (f *NFT) parseNATTarget(tok string) (addr string, port uint16, err error) { // output within the given chain, returning the parsed rule and its handle. func (f *NFT) UnmarshalNATRule(line string, chain string) (r *NATRule, handle string, err error) { r = &NATRule{} - tokens := strings.Fields(f.collapseSetSpaces(line)) + tokens := f.splitQuoted(f.collapseSetSpaces(line)) for i := 0; i < len(tokens); i++ { switch tokens[i] { case "ip", "ip6": @@ -1483,11 +1772,19 @@ func (f *NFT) listNATChain(ctx context.Context, chain string) (rules []*NATRule, } for _, line := range out { line = strings.TrimSpace(line) - if line == "" || !strings.Contains(line, "handle ") { + // Only rule lines carry a handle; skip table/chain scaffolding. An + // unmodeled row is kept as an opaque slot — a nil rule with its handle — + // exactly as listChain does for filter rows. + if line == "" || !strings.Contains(line, "handle ") || + strings.HasPrefix(line, "table ") || strings.HasPrefix(line, "chain ") { continue } rule, handle, perr := f.UnmarshalNATRule(line, chain) if perr != nil { + if h := f.lineHandle(line); h != "" { + rules = append(rules, nil) + handles = append(handles, h) + } continue } // NAT rules live in this backend's own table; membership is what sets @@ -1509,7 +1806,12 @@ func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) { if cerr != nil { return nil, cerr } - rules = append(rules, chainRules...) + // Opaque (nil) rows stay in the chain but are not reportable rules. + for _, r := range chainRules { + if r != nil { + rules = append(rules, r) + } + } } // The nat chains live in the same inet table, so a family-agnostic translation is // one unpinned row that reads back as FamilyAny; nothing is collapsed here. Number @@ -1572,6 +1874,11 @@ func (f *NFT) MarshalNATRule(r *NATRule) (chain string, expr string, err error) if err := r.validate(); err != nil { return "", "", err } + // A named set is family-typed; see MarshalRule's matching check. The NAT + // entry points pin the rule first (resolveNATSetRefFamily). + if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) { + return "", "", fmt.Errorf("a set-referencing rule requires a concrete family; the caller must resolve the set's family first") + } fam := r.impliedFamily() var parts []string @@ -1654,10 +1961,10 @@ func (f *NFT) ensureNATChains(ctx context.Context) error { return nil } -// natRuleExists is ruleExists for NAT rules. +// natRuleExists is ruleExists for NAT rules. Opaque (nil) rows never match. func (f *NFT) natRuleExists(existing []*NATRule, r *NATRule) bool { for _, e := range existing { - if e.EqualForDedup(r) { + if e != nil && e.EqualForDedup(r) { return true } } @@ -1670,6 +1977,12 @@ func (f *NFT) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error return err } + // A family-agnostic set reference is pinned to the set's own family. + r, err := f.resolveNATSetRefFamily(ctx, r) + if err != nil { + return err + } + chain, expr, err := f.MarshalNATRule(r) if err != nil { return err @@ -1683,7 +1996,7 @@ func (f *NFT) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error return nil } - args := append([]string{"add", "rule", "inet", f.table, chain}, strings.Fields(expr)...) + args := append([]string{"add", "rule", "inet", f.table, chain}, f.splitQuoted(expr)...) _, err = runCommand(ctx, "nft", args...) return err } @@ -1696,6 +2009,12 @@ func (f *NFT) InsertNATRule(ctx context.Context, zoneName string, position int, return err } + // A family-agnostic set reference is pinned to the set's own family. + r, err := f.resolveNATSetRefFamily(ctx, r) + if err != nil { + return err + } + chain, expr, err := f.MarshalNATRule(r) if err != nil { return err @@ -1712,9 +2031,9 @@ func (f *NFT) InsertNATRule(ctx context.Context, zoneName string, position int, if position <= 0 { position = 1 } - // Each nat-chain row is its own rule, so a Number is already a physical index; a - // position past the last row appends. - insPos := min(position-1, len(existing)) + // A position counts the modeled rows, matching GetNATRules' numbering; + // physicalIndex maps it past any opaque rows to the chain's real index. + insPos := physicalIndex(existing, position) args := f.placeArgs(chain, expr, insPos, len(existing)) _, err = runCommand(ctx, "nft", args...) return err @@ -1737,12 +2056,29 @@ func (f *NFT) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) er } // Delete every matching row (see RemoveRule): a FamilyAny NAT target must clear // both the unpinned row it names and any family-pinned rows it covers, while a - // concrete-family target removes only its own family. + // concrete-family target removes only its own family. A concrete-family target + // that matched a genuine dual-family row re-adds the untargeted family in the + // row's slot, so a bare masquerade/redirect does not silently stop translating + // the other family. + deleted := 0 for i, e := range rules { - if e.EqualForRemoval(r) { - if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil { + if e == nil || !e.EqualForRemoval(r) { + continue + } + if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil { + return err + } + deleted++ + if s := splitNATDualRow(e, r); s != nil { + _, expr, merr := f.MarshalNATRule(s) + if merr != nil { + return merr + } + args := f.placeArgs(chain, expr, i-deleted+1, len(rules)-deleted) + if _, err := runCommand(ctx, "nft", args...); err != nil { return err } + deleted-- } } return nil @@ -1838,18 +2174,20 @@ type nftSetJSON struct { // nftListJSON is the top-level envelope every `nft -j list ...` command emits: a // "nftables" array whose items are single-key objects (metainfo, set, rule, ...). -// We only pull the "set" objects out. +// Only the "set" objects are pulled out. type nftListJSON struct { Nftables []struct { Set *nftSetJSON `json:"set"` } `json:"nftables"` } -// decodeSets unwraps the `nft -j list set(s)` envelope into its set objects. -func (f *NFT) decodeSets(out []string) []*nftSetJSON { +// decodeSets unwraps the `nft -j list set(s)` envelope into its set objects. A +// malformed envelope is an error: reading it as "no sets" would let a Backup +// silently capture zero sets. +func (f *NFT) decodeSets(out []string) ([]*nftSetJSON, error) { var env nftListJSON if err := json.Unmarshal([]byte(strings.Join(out, "\n")), &env); err != nil { - return nil + return nil, fmt.Errorf("failed to decode nft set listing: %s", err) } var sets []*nftSetJSON for _, item := range env.Nftables { @@ -1857,7 +2195,19 @@ func (f *NFT) decodeSets(out []string) []*nftSetJSON { sets = append(sets, item.Set) } } - return sets + return sets, nil +} + +// nftSetIsDynamic reports whether a listed set carries the dynamic flag — the +// mark of a per-source connection-limit meter rather than a caller-managed +// address set. +func nftSetIsDynamic(s *nftSetJSON) bool { + for _, fl := range s.Flags { + if fl == "dynamic" { + return true + } + } + return false } // decodeElem decodes one JSON element of an nft set into its string form. A @@ -1907,11 +2257,19 @@ func (f *NFT) getAddressSet(ctx context.Context, name string) (*AddressSet, erro } return nil, err } - sets := f.decodeSets(out) + sets, err := f.decodeSets(out) + if err != nil { + return nil, err + } if len(sets) == 0 { return nil, nil } detail := sets[0] + // A dynamic set is connection-limit counting state, not an address set; + // report it as not-found so no caller manages it as data. + if nftSetIsDynamic(detail) { + return nil, nil + } set := &AddressSet{Name: name} switch detail.Type { case "ipv6_addr": @@ -1944,12 +2302,21 @@ func (f *NFT) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { if err != nil { return nil, err } - sets := f.decodeSets(out) + sets, err := f.decodeSets(out) + if err != nil { + return nil, err + } result := make([]*AddressSet, 0, len(sets)) for _, s := range sets { if s.Table != f.table { continue } + // A dynamic set is a per-source connection-limit meter, rule mechanics + // rather than a caller-managed address set; reporting one would let a + // Backup/Restore or a caller's sweep manage counting state as data. + if nftSetIsDynamic(s) { + continue + } detail, err := f.getAddressSet(ctx, s.Name) if err != nil { return nil, err @@ -2127,16 +2494,21 @@ func (f *NFT) Restore(ctx context.Context, zoneName string, backup *Backup) erro return err } - // Flush the private table, then re-add all rules. - if _, err := runCommand(ctx, "nft", "flush", "table", "inet", f.table); err != nil { + // Clear the modeled rows by handle rather than flushing the table: an + // unmodeled row (a foreign construct hand-added into the private table) is + // invisible to Backup, so a flush would destroy state the snapshot cannot + // reproduce. + if err := f.clearModeledRows(ctx); err != nil { return err } // Recreate the sets on a clean slate before the rules that reference them. The - // flush above cleared every rule in the table, so no rule holds a set reference - // and each set can be removed and rebuilt; the clean rebuild is required because - // nft's AddAddressSet is a no-op on an existing set and would not otherwise - // restore a flushed set's elements. + // clear above removed every modeled rule, so no modeled rule holds a set + // reference and each set can be removed and rebuilt; the clean rebuild is + // required because nft's AddAddressSet is a no-op on an existing set and would + // not otherwise restore a flushed set's elements. An unmodeled row that still + // references a set surfaces here as a delete-set error rather than being + // silently destroyed. if err := restoreBackupSets(ctx, f, backup, true); err != nil { return err } @@ -2207,28 +2579,118 @@ func (f *NFT) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) return err } -// ReplaceRulesBatch atomically flushes the private table's input and output -// chains and re-adds exactly rules, all in one `nft -f` transaction. Chain hooks -// and policies are preserved (flush chain removes only rules). It implements -// RuleBatcher. +// clearModeledRows deletes every modeled rule row from the private table's +// filter and nat chains by handle, in one `nft -f` transaction, leaving +// unmodeled (opaque) rows in place. Chain hooks and policies are untouched. +// The cleared rows' per-source meter sets are swept afterwards, best-effort, +// so a restore does not strand counting state; a rule the restore re-adds +// auto-creates its set again. +func (f *NFT) clearModeledRows(ctx context.Context) error { + var script strings.Builder + staleMeters := map[string]bool{} + for _, chain := range nftFilterChains { + rules, handles, err := f.listChain(ctx, chain) + if err != nil { + return err + } + for i, e := range rules { + if e != nil { + fmt.Fprintf(&script, "delete rule inet %s %s handle %s\n", f.table, chain, handles[i]) + if e.meterSet != "" { + staleMeters[e.meterSet] = true + } + } + } + } + for _, chain := range []string{"prerouting", "postrouting"} { + rules, handles, err := f.listNATChain(ctx, chain) + if err != nil { + return err + } + for i, e := range rules { + if e != nil { + fmt.Fprintf(&script, "delete rule inet %s %s handle %s\n", f.table, chain, handles[i]) + } + } + } + if script.Len() == 0 { + return nil + } + if _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-"); err != nil { + return err + } + for name := range staleMeters { + _, _ = runCommand(ctx, "nft", "delete", "set", "inet", f.table, name) + } + return nil +} + +// ReplaceRulesBatch atomically replaces the private table's filter rules with +// exactly rules, all in one `nft -f` transaction. Every filter chain is +// covered — forward included — by deleting the modeled rows by handle, so +// unmodeled (opaque) rows, chain hooks and policies are preserved. It +// implements RuleBatcher. func (f *NFT) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { if err := f.ensureTable(ctx); err != nil { return err } var script strings.Builder - fmt.Fprintf(&script, "flush chain inet %s input\n", f.table) - fmt.Fprintf(&script, "flush chain inet %s output\n", f.table) - for _, top := range rules { - // A DirAny rule fans out into an input row plus its swapped output row. - for _, r := range expandDirections(top) { - chain, expr, err := f.MarshalRule(r) - if err != nil { - return err + // Meter sets referenced by the deleted rows are garbage once their rows go; + // collect them for the sweep below, keeping any a new rule still names. + staleMeters := map[string]bool{} + for _, chain := range nftFilterChains { + existing, handles, err := f.listChain(ctx, chain) + if err != nil { + return err + } + for i, e := range existing { + if e != nil { + fmt.Fprintf(&script, "delete rule inet %s %s handle %s\n", f.table, chain, handles[i]) + if e.meterSet != "" { + staleMeters[e.meterSet] = true + } } - fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr) } } - _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-") - return err + for _, top := range rules { + // A DirAny rule fans out into an input row plus its swapped output row, + // and a FamilyAny per-source connection limit into one row per family. + for _, d := range expandDirections(top) { + subs := []*Rule{d} + if perSourceFamilySplit(d) { + subs = expandFamilies(d) + } + for _, r := range subs { + r, err := f.resolveSetRefFamily(ctx, r) + if err != nil { + return err + } + chain, expr, err := f.MarshalRule(r) + if err != nil { + return err + } + fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr) + if r.perSourceLimited() { + // The meter statement auto-creates (or reuses) this set; a + // reused set keeps its counting state across the replace. + delete(staleMeters, f.meterName(chain, r)) + } + } + } + } + if script.Len() == 0 { + return nil + } + if _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-"); err != nil { + return err + } + // Sweep the now-unreferenced meter sets outside the transaction, best-effort: + // a delete that fails (an opaque row still references the set) leaves it in + // place rather than aborting the whole replace. An unchanged per-source rule + // was pruned from the sweep, so its set — and counting state — survives. + for name := range staleMeters { + _, _ = runCommand(ctx, "nft", "delete", "set", "inet", f.table, name) + } + return nil } diff --git a/nft_linux_test.go b/nft_linux_test.go index 00e2400..295713f 100644 --- a/nft_linux_test.go +++ b/nft_linux_test.go @@ -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") +} diff --git a/pf.go b/pf.go index 51ed59c..7b1d6e2 100644 --- a/pf.go +++ b/pf.go @@ -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 + // 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 - // 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 + // 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) } diff --git a/pf_test.go b/pf_test.go index ff988a3..4aea5d3 100644 --- a/pf_test.go +++ b/pf_test.go @@ -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)) +} diff --git a/services.go b/services.go index 6a6cebb..8d5eb28 100644 --- a/services.go +++ b/services.go @@ -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 diff --git a/ufw_linux.go b/ufw_linux.go index 752b68a..943be28 100644 --- a/ufw_linux.go +++ b/ufw_linux.go @@ -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 } diff --git a/ufw_linux_test.go b/ufw_linux_test.go index 3fbb010..c02cf55 100644 --- a/ufw_linux_test.go +++ b/ufw_linux_test.go @@ -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") +} diff --git a/utils.go b/utils.go index 52bf43f..0e6275f 100644 --- a/utils.go +++ b/utils.go @@ -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. diff --git a/wf_windows.go b/wf_windows.go index de227b2..451e95d 100644 --- a/wf_windows.go +++ b/wf_windows.go @@ -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 { diff --git a/wf_windows_test.go b/wf_windows_test.go index 9b60684..f797d55 100644 --- a/wf_windows_test.go +++ b/wf_windows_test.go @@ -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) +}