Advertise capability flags and harden backend semantics
- New Capabilities: PortPair, Negation, RejectAction, FamilyWithoutAddress, DenyActionFromConfig, advertised per backend. - coversDirection isolates DirForward even when output is unowned; add splitNATDualRow so a concrete-family removal re-adds the opposite family's NAT translation. - Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the "invalid" sentinel as input while JSON round-trips it. - Sync counts additions on mid-batch failure and uses RuleBatcher. - NewManager runs a probe loop joining each backend's reason for diagnosability; services.go drops "generated" from enabled, handles it on enable, clears start-limit-hit on restart, and matches rc.local by token. - nftables: per-source connection limits (meter set), quoted-token parsing preserving log-prefix spacing, digit-led prefix sanitizing. - apf/csf: deny-action-from-config with cached STOP settings, port lists and inexpressible shapes routed through the pre-hook, confKeyApplies guard against a missing config line. - atomic config writes fsync before rename and resolve symlinks; readConfValue is last-assignment-wins; runCommand preserves the exit code through the wrapped error. - Move coreos/go-systemd to the maintained v22 module directly.
This commit is contained in:
parent
b4e54a66b6
commit
5095d90fa4
34 changed files with 4448 additions and 1459 deletions
2
Makefile
2
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
|
||||
|
|
|
|||
12
README.md
12
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}
|
||||
|
|
|
|||
267
apf_linux.go
267
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
27
backup.go
27
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
405
csf_linux.go
405
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 <ip>`), which csf fails to parse and drops silently. A
|
||||
// source+destination pair overflows the single address field and is routed by
|
||||
// advRuleNeedsHook. ICMPv6 never reaches this test — ruleNeedsHook sends it to the
|
||||
// hook, or the IPv6 gate rejects it.
|
||||
func (f *CSF) nativeICMP(r *Rule) bool {
|
||||
return r.Proto == ICMP && r.ICMPType != nil && (r.Source != "") != (r.Destination != "")
|
||||
}
|
||||
|
||||
// needsHook reports whether a rule must be injected through the csf pre-hook as a
|
||||
// raw iptables rule because csf's native config cannot express it. It is the single
|
||||
// gate between the hook path and csf's config files: everything it rejects (returns
|
||||
// true) is written to the hook, everything it accepts (returns false) maps onto
|
||||
// csf.conf or the csf.allow/csf.deny lists. The shared shapes (ruleNeedsHook,
|
||||
// bareHostOneWay, hostNeedsHook, advRuleNeedsHook, bareProtoNeedsHook) keep their own
|
||||
// predicates, since RemoveRule routes on ruleNeedsHook and bareHostOneWay directly.
|
||||
// csf.conf or the csf.allow/csf.deny lists. The shared predicates (ruleNeedsHook,
|
||||
// shapeNeedsHook, bareHostOneWay) stay standalone, since APF shares them and
|
||||
// RemoveRule routes on ruleNeedsHook and bareHostOneWay directly.
|
||||
func (f *CSF) needsHook(r *Rule) bool {
|
||||
// Features csf's native config cannot express (connection state, per-rule
|
||||
// interface, logging, rate limiting, forward-chain routing, icmpv6, a transport
|
||||
|
|
@ -1078,17 +1106,18 @@ func (f *CSF) needsHook(r *Rule) bool {
|
|||
if ruleNeedsHook(r) {
|
||||
return true
|
||||
}
|
||||
// A one-way bare host has no csf.allow/csf.deny form (a plain line is
|
||||
// bidirectional, an advanced rule needs a port), and a concrete-protocol host or a
|
||||
// source+destination pair likewise has none (see hostNeedsHook); all go to the hook.
|
||||
if bareHostOneWay(r) || hostNeedsHook(r) {
|
||||
// A shape no native csf form holds — a one-way bare host, a source+destination
|
||||
// pair, a concrete-protocol portless host, an advanced-line address/port-flow
|
||||
// overflow, or a bare protocol match (see shapeNeedsHook) — goes to the hook.
|
||||
if shapeNeedsHook(r) {
|
||||
return true
|
||||
}
|
||||
// A ported source+destination pair, a source port matched with a destination port,
|
||||
// and an address-less source-port match all overflow the advanced line's single
|
||||
// address and single port-flow field (see advRuleNeedsHook); iptables matches each
|
||||
// directly, so they go to the hook.
|
||||
if advRuleNeedsHook(r) {
|
||||
// An address-less multi-port accept goes to the hook's `-m multiport` match:
|
||||
// a csf.conf port list stores each port as an independent token that reads
|
||||
// back as its own rule, so the list shape has no single native form (see
|
||||
// multiPortConfAccept). An addressed multi-port rule stays native — an
|
||||
// advanced line's port field is a comma list.
|
||||
if f.multiPortConfAccept(r) {
|
||||
return true
|
||||
}
|
||||
// A connection limit csf.conf's CONNLIMIT cannot express — anything but a
|
||||
|
|
@ -1097,16 +1126,32 @@ func (f *CSF) needsHook(r *Rule) bool {
|
|||
if r.ConnLimit != nil && !f.isConnLimitRule(r) {
|
||||
return true
|
||||
}
|
||||
// An ICMPv4 rule csf's advanced-rule format cannot carry (see nativeICMP) goes to
|
||||
// the hook's `iptables -p icmp` match, which needs neither an address nor a type.
|
||||
if r.Proto == ICMP && !f.nativeICMP(r) {
|
||||
return true
|
||||
}
|
||||
// A bare protocol match with no address and no port has no native csf construct —
|
||||
// csf.conf keys on a port, an advanced rule on address+port, and csf.allow/csf.deny
|
||||
// on an address — but iptables expresses it directly, so it goes to the hook (see
|
||||
// bareProtoNeedsHook). ICMP is excluded there and routed above.
|
||||
return bareProtoNeedsHook(r)
|
||||
// An ICMPv4 rule csf's advanced-rule format cannot carry goes to the hook's
|
||||
// `iptables -p icmp` match, which needs neither an address nor a type. The only
|
||||
// native form is exactly one address with a concrete type: an advanced line
|
||||
// requires an address, its single port-flow field carries the icmp type, and
|
||||
// csf.pl's linefilter reads that field by position — an address with no type
|
||||
// would land the address there (`--icmp-type <ip>`), which csf fails to parse
|
||||
// and drops silently. A source+destination pair overflows the single address
|
||||
// field and is routed by shapeNeedsHook above; ICMPv6 never reaches this test —
|
||||
// ruleNeedsHook sends it to the hook, or the IPv6 gate rejects it.
|
||||
return r.Proto == ICMP && !(r.ICMPType != nil && (r.Source != "") != (r.Destination != ""))
|
||||
}
|
||||
|
||||
// multiPortConfAccept reports whether a rule is an address-less tcp/udp accept
|
||||
// carrying a discrete multi-port list — the one port shape a csf.conf port list
|
||||
// cannot hold as a single rule: TCP_IN="80,443" stores independent tokens that
|
||||
// read back one rule per port, so the rule would never round-trip whole. AddRule
|
||||
// injects it through the hook's `-m multiport` match, which keeps the list on
|
||||
// one line; a single port or one range stays a native token. A connection-limited
|
||||
// rule is excluded (CONNLIMIT routing owns it), as is a source-port match, which
|
||||
// is not the port-list shape and is routed by shapeNeedsHook — the exclusions let
|
||||
// RemoveRule sweep the csf.conf port lists for this shape without touching tokens
|
||||
// other rules own.
|
||||
func (f *CSF) multiPortConfAccept(r *Rule) bool {
|
||||
return onProtocolAxis(r.Proto) && r.Action == Accept && r.ConnLimit == nil &&
|
||||
r.Source == "" && r.Destination == "" && !r.HasSourcePorts() &&
|
||||
len(r.PortSpecs()) > 1
|
||||
}
|
||||
|
||||
// denyAction returns the action a csf.deny entry takes in the given direction,
|
||||
|
|
@ -1168,10 +1213,11 @@ func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6
|
|||
|
||||
// Any shape csf's native config cannot express (a stateful/interface/logged/
|
||||
// rate-limited rule, a one-way or concrete-protocol host, a source+destination
|
||||
// pair, a source-and-destination port match, an address-less source-port match, a
|
||||
// non-native connection limit, a non-native ICMPv4 rule, or a bare protocol match)
|
||||
// is injected as a raw iptables rule through the csf pre-hook. See needsHook for
|
||||
// each clause; everything past this gate maps onto csf's own config files.
|
||||
// pair, a source-and-destination port match, an address-less source-port match,
|
||||
// an address-less multi-port accept, a non-native connection limit, a non-native
|
||||
// ICMPv4 rule, or a bare protocol match) is injected as a raw iptables rule
|
||||
// through the csf pre-hook. See needsHook for each clause; everything past this
|
||||
// gate maps onto csf's own config files.
|
||||
if f.needsHook(r) {
|
||||
_, err := f.hook().edit(r, false)
|
||||
return err
|
||||
|
|
@ -1182,12 +1228,12 @@ func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6
|
|||
return f.EditConf(ctx, r, false)
|
||||
}
|
||||
|
||||
// A port-only accept maps to a csf.conf port list rather than csf.allow.
|
||||
// A port-only accept maps to a csf.conf port list rather than csf.allow;
|
||||
// listRows has no row for it, so falling through to the csf.allow edit would
|
||||
// only rewrite that file byte-identically. Only a single port or one range
|
||||
// reaches here — needsHook diverted a multi-port list to the hook above.
|
||||
if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept {
|
||||
err := f.EditConf(ctx, r, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.EditConf(ctx, r, false)
|
||||
}
|
||||
|
||||
// Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny
|
||||
|
|
@ -1265,6 +1311,13 @@ func (f *CSF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule
|
|||
return err
|
||||
}
|
||||
if s := splitDualRowDirection(e, r); s != nil {
|
||||
// A deny plain line's outbound half was enforced with csf.conf's
|
||||
// DROP_OUT action, while the DirAny row reads back with the inbound
|
||||
// action; re-express the survivor with the action csf actually applied
|
||||
// so the split does not silently change wire behavior.
|
||||
if s.Action != Accept && s.IsOutput() {
|
||||
_, s.Action = f.dropActions()
|
||||
}
|
||||
_, err := f.hook().edit(s, false)
|
||||
return err
|
||||
}
|
||||
|
|
@ -1301,6 +1354,14 @@ func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Validate the shape before the hook sweep below: an iptables-inexpressible
|
||||
// rule (a port on ProtocolAny) exists nowhere csf can hold it, and letting it
|
||||
// fail inside the hook marshal would return the bare error without the
|
||||
// sentinel AddRule attaches to the same shape.
|
||||
if err := iptablesRuleValid(r); err != nil {
|
||||
return fmt.Errorf("%v: %w", err, ErrUnsupported)
|
||||
}
|
||||
|
||||
// Clear any hook copy of the rule first, no matter how csf stores it. A rule csf
|
||||
// carries only in the hook (see needsHook) lives nowhere else, so this is its
|
||||
// entire removal; a natively-expressible rule may still have a stray hook copy —
|
||||
|
|
@ -1331,12 +1392,12 @@ func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
|||
}
|
||||
// Every other shape csf's native config cannot express (see needsHook) has already
|
||||
// had its hook copy cleared above and has no native entry to split, so it is done.
|
||||
if f.needsHook(r) {
|
||||
// An address-less multi-port accept is the exception: it lives in the hook now,
|
||||
// but an earlier per-port add (or a manual edit) may also hold its ports as
|
||||
// csf.conf tokens, so it falls through to the port-list sweep below.
|
||||
if f.needsHook(r) && !f.multiPortConfAccept(r) {
|
||||
return nil
|
||||
}
|
||||
if err := iptablesRuleValid(r); err != nil {
|
||||
return fmt.Errorf("%v: %w", err, ErrUnsupported)
|
||||
}
|
||||
|
||||
// A native connection-limit rule maps onto the csf.conf CONNLIMIT list.
|
||||
if r.ConnLimit != nil {
|
||||
|
|
@ -1433,40 +1494,82 @@ func (f *CSF) UnmarshalNATRule(line string) *NATRule {
|
|||
return r
|
||||
}
|
||||
|
||||
// GetNATRules reads the NAT rules from csf.redirect.
|
||||
func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
|
||||
fd, err := os.Open(CSFRedirect)
|
||||
if err != nil {
|
||||
// csf.redirect is optional; a missing file simply has no rules.
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
// natNeedsHook reports whether a NAT rule has no csf.redirect form and must be
|
||||
// injected as a raw nat-table command through the pre-hook instead. csf.redirect
|
||||
// encodes destination NAT only, in exactly two DNAT shapes plus the local
|
||||
// redirect, always tcp/udp on a single concrete port, with no source or
|
||||
// interface match (see MarshalNATRule). iptables expresses all of the excess —
|
||||
// source NAT, an interface binding, a port range — directly, so those shapes are
|
||||
// hooked rather than rejected.
|
||||
func (f *CSF) natNeedsHook(r *NATRule) bool {
|
||||
if r.Kind.isSource() {
|
||||
return true
|
||||
}
|
||||
defer func() { _ = fd.Close() }()
|
||||
if r.Proto != TCP && r.Proto != UDP {
|
||||
return true
|
||||
}
|
||||
if r.HasPortSet() {
|
||||
return true
|
||||
}
|
||||
if r.Source != "" || r.Interface != "" {
|
||||
return true
|
||||
}
|
||||
// A csf.redirect line carries family only through its addresses, so a family
|
||||
// pinned by the Family field alone (an address-less redirect) has no native
|
||||
// form: it would read back family-agnostic and never reconcile.
|
||||
if r.Family != FamilyAny && familyOfAddr(r.Destination) == FamilyAny && familyOfAddr(r.ToAddress) == FamilyAny {
|
||||
return true
|
||||
}
|
||||
switch r.Kind {
|
||||
case Redirect:
|
||||
return r.Port == 0
|
||||
case DNAT:
|
||||
// csf.pl accepts a DNAT only as a full-IP forward (both ports "*") or a
|
||||
// port forward (both ports concrete); any other pairing aborts the load.
|
||||
return r.Destination == "" || (r.Port == 0) != (r.ToPort == 0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetNATRules reads the NAT rules from csf.redirect and the raw nat-table
|
||||
// commands the pre-hook carries.
|
||||
func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
|
||||
var rules []*NATRule
|
||||
scanner := bufio.NewScanner(fd)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if ci := strings.IndexByte(line, '#'); ci >= 0 {
|
||||
line = line[:ci]
|
||||
fd, err := os.Open(CSFRedirect)
|
||||
switch {
|
||||
case err == nil:
|
||||
defer func() { _ = fd.Close() }()
|
||||
scanner := bufio.NewScanner(fd)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if ci := strings.IndexByte(line, '#'); ci >= 0 {
|
||||
line = line[:ci]
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if r := f.UnmarshalNATRule(line); r != nil {
|
||||
rules = append(rules, r)
|
||||
}
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r := f.UnmarshalNATRule(line); r != nil {
|
||||
rules = append(rules, r)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
case os.IsNotExist(err):
|
||||
// csf.redirect is optional; a missing file simply has no native rules.
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
// csf.redirect is CSF's own NAT config with no per-rule prefix marker, so no
|
||||
// rule in it carries the configured prefix; HasPrefix stays false (mirroring
|
||||
// firewalld's zones).
|
||||
return rules, nil
|
||||
// firewalld's zones). Hook NAT lines are raw iptables commands, so a rule this
|
||||
// library added there carries the prefix in its -m comment tag instead.
|
||||
hookNAT, err := f.hook().getNATRules()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(rules, hookNAT...), nil
|
||||
}
|
||||
|
||||
// redirectAddr renders an address for a csf.redirect field, using "*" for an
|
||||
|
|
@ -1491,7 +1594,8 @@ func (f *CSF) redirectPort(p uint16) string {
|
|||
// ("IPx|portA|IPy|portB|proto"). csf.redirect expresses only destination NAT: a
|
||||
// Redirect to a local port (IPy = "*") or a DNAT forward to another host
|
||||
// (IPy = ToAddress). Source NAT, port ranges/lists, and non-tcp/udp protocols
|
||||
// are not representable.
|
||||
// have no line here — AddNATRule routes those shapes through the pre-hook
|
||||
// (natNeedsHook), so the rejections below guard only direct marshal callers.
|
||||
func (f *CSF) MarshalNATRule(r *NATRule) (string, error) {
|
||||
if err := r.validate(); err != nil {
|
||||
return "", err
|
||||
|
|
@ -1603,8 +1707,24 @@ func (f *CSF) editRedirect(r *NATRule, remove bool) error {
|
|||
return writeConfigFile(CSFRedirect, []byte(content), 0600)
|
||||
}
|
||||
|
||||
// AddNATRule adds a NAT rule to csf.redirect.
|
||||
// AddNATRule adds a NAT rule to csf.redirect, or — for the shapes csf.redirect
|
||||
// cannot hold (natNeedsHook) — as a raw nat-table command in the pre-hook.
|
||||
func (f *CSF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
||||
if err := r.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
// A concrete-IPv6 translation cannot be kept in sync with IPV6 off: csf
|
||||
// sources the pre-hook on every (re)load but only flushes the v6 nat table
|
||||
// when IPV6 is on, so an injected ip6tables line would re-append each reload
|
||||
// and outlive its own removal, and a csf.redirect v6 entry is never applied
|
||||
// at all — the NAT analog of ipv6Unavailable.
|
||||
if !f.ipv6Enabled && r.impliedFamily() == IPv6 {
|
||||
return fmt.Errorf("csf cannot manage an IPv6 nat rule with IPV6 disabled: %w", ErrUnsupportedNAT)
|
||||
}
|
||||
if f.natNeedsHook(r) {
|
||||
_, err := f.hook().editNAT(r, false)
|
||||
return err
|
||||
}
|
||||
return f.editRedirect(r, false)
|
||||
}
|
||||
|
||||
|
|
@ -1614,8 +1734,20 @@ func (f *CSF) InsertNATRule(ctx context.Context, zoneName string, position int,
|
|||
return unsupportedOrdering(f.Type())
|
||||
}
|
||||
|
||||
// RemoveNATRule removes a NAT rule from csf.redirect.
|
||||
// RemoveNATRule removes a NAT rule from csf.redirect and from the pre-hook.
|
||||
// The hook is swept first whatever the rule's shape: a hook-only shape lives
|
||||
// nowhere else, and a natively-expressible rule may still have a stray hook
|
||||
// copy a customer added by hand, mirroring the filter-side RemoveRule.
|
||||
func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
||||
if err := r.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.hook().editNAT(r, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.natNeedsHook(r) {
|
||||
return nil
|
||||
}
|
||||
return f.editRedirect(r, true)
|
||||
}
|
||||
|
||||
|
|
@ -1659,6 +1791,7 @@ func (f *CSF) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
|||
}
|
||||
|
||||
// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a
|
||||
// hook rule still references the set; removing an absent set is a no-op.
|
||||
func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error {
|
||||
_, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
166
firewall.go
166
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,16 @@ import (
|
|||
firewalld "github.com/grmrgecko/go-firewalld"
|
||||
)
|
||||
|
||||
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager
|
||||
// interface onto firewalld's zones, rich rules, and ipsets.
|
||||
type FirewallD struct {
|
||||
Conn *firewalld.Conn
|
||||
// rulePrefix is accepted for a consistent constructor signature across
|
||||
// backends. firewalld organizes rules into zones rather than a private
|
||||
// namespace, so the prefix is not applied to individual rules.
|
||||
rulePrefix string
|
||||
}
|
||||
|
||||
// NewFirewallD connects to firewalld and returns a manager, or an error when
|
||||
// firewalld cannot be reached.
|
||||
func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) {
|
||||
|
|
@ -35,21 +45,27 @@ func (f *FirewallD) Type() string {
|
|||
// Capabilities reports which optional features this backend supports.
|
||||
func (f *FirewallD) Capabilities() Capabilities {
|
||||
return Capabilities{
|
||||
Output: false,
|
||||
Zones: true,
|
||||
Priority: true,
|
||||
ICMPv6: true,
|
||||
PortList: false,
|
||||
ConnState: false,
|
||||
InterfaceMatch: false,
|
||||
Logging: true,
|
||||
RateLimit: true,
|
||||
ConnLimit: false,
|
||||
NAT: true,
|
||||
RuleOrdering: false,
|
||||
DefaultPolicy: true,
|
||||
RuleCounters: false,
|
||||
AddressSets: true,
|
||||
Output: false,
|
||||
Zones: true,
|
||||
Priority: true,
|
||||
ICMPv6: true,
|
||||
PortList: false,
|
||||
// A rich rule carries a single port element, so a source-port match
|
||||
// cannot be combined with a destination-port match in one rule.
|
||||
PortPair: false,
|
||||
ConnState: false,
|
||||
InterfaceMatch: false,
|
||||
Logging: true,
|
||||
RateLimit: true,
|
||||
ConnLimit: false,
|
||||
NAT: true,
|
||||
RuleOrdering: false,
|
||||
DefaultPolicy: true,
|
||||
RuleCounters: false,
|
||||
AddressSets: true,
|
||||
Negation: true,
|
||||
RejectAction: true,
|
||||
FamilyWithoutAddress: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,21 +75,19 @@ func (f *FirewallD) GetZone(ctx context.Context, iface string) (zoneName string,
|
|||
// Ask firewalld directly which permanent zone the interface is bound to.
|
||||
// This returns the zone id (e.g. "public"), which is what the other backend
|
||||
// methods expect to pass back into Permanent().Zone. An empty result or an
|
||||
// error means the interface is not bound to a zone, so we fall through to
|
||||
// the default zone below.
|
||||
// error means the interface is not bound to a zone, leaving the default
|
||||
// zone below.
|
||||
zoneName, err = f.Conn.Permanent().ZoneOfInterface(ctx, iface)
|
||||
if err == nil && zoneName != "" {
|
||||
return zoneName, nil
|
||||
}
|
||||
|
||||
// If we did not find a zone for the specified interface, use the default
|
||||
// zone if it exists.
|
||||
// An unbound interface belongs to the default zone.
|
||||
defaultZone, derr := f.Conn.DefaultZone(ctx)
|
||||
if derr == nil && defaultZone != "" {
|
||||
return defaultZone, nil
|
||||
}
|
||||
|
||||
// If we were unable to find the zone or a default zone, error out.
|
||||
return "", fmt.Errorf("unable to find zone")
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +239,10 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
|||
}
|
||||
|
||||
// Parse the destination, which firewalld expresses as either an address
|
||||
// or an ipset (mirroring the source grammar).
|
||||
// or an ipset (mirroring the source grammar). firewalld has no output
|
||||
// chain, so a destination element is an inbound-frame destination match,
|
||||
// not a direction signal — the write side stores every rule in the
|
||||
// inbound frame (see AddRule).
|
||||
if strings.HasPrefix(tokens[i], "address=") {
|
||||
address := trimQuotes(strings.TrimPrefix(tokens[i], "address="))
|
||||
if not {
|
||||
|
|
@ -233,7 +250,6 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
|||
} else {
|
||||
r.Destination = address
|
||||
}
|
||||
r.Direction = DirOutput
|
||||
} else if strings.HasPrefix(tokens[i], "ipset=") {
|
||||
ipset := trimQuotes(strings.TrimPrefix(tokens[i], "ipset="))
|
||||
if not {
|
||||
|
|
@ -241,7 +257,6 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
|||
} else {
|
||||
r.Destination = ipset
|
||||
}
|
||||
r.Direction = DirOutput
|
||||
} else {
|
||||
return nil, fmt.Errorf("the destination argument has no address or ipset defined")
|
||||
}
|
||||
|
|
@ -257,6 +272,13 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
|||
}
|
||||
i++
|
||||
}
|
||||
// A limit element directly after log throttles only the logging, which
|
||||
// RateLimit cannot hold — it models a traffic limit. Reading it as one
|
||||
// would let a rewrite turn a log throttle into a traffic throttle, so
|
||||
// the rule stays unmodeled.
|
||||
if i+1 < len(tokens) && tokens[i+1] == "limit" {
|
||||
return nil, fmt.Errorf("a log-scoped limit is unsupported")
|
||||
}
|
||||
} else if tokens[i] == "limit" {
|
||||
// A rule-level rate limit: limit value="N/unit" where unit is one of
|
||||
// s/m/h/d. Parse it into the rule's RateLimit.
|
||||
|
|
@ -380,11 +402,17 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
|||
// An icmp-type element restricts to a single ICMP message type by
|
||||
// firewalld name, e.g. icmp-type name="echo-request". The numeric type
|
||||
// and the ICMP protocol both depend on the rule's family, which appears
|
||||
// earlier in the rule string, so r.Family is already set here.
|
||||
// earlier in the rule string, so r.Family is already set here. A
|
||||
// familyless icmp-type rule (foreign; firewalld applies it to both
|
||||
// families) has no single ICMP/ICMPv6 form, so it stays unmodeled
|
||||
// rather than reading back as its IPv4 half.
|
||||
i++
|
||||
if i >= len(tokens) || !strings.HasPrefix(tokens[i], "name=") {
|
||||
return nil, fmt.Errorf("the icmp-type element has no defined name")
|
||||
}
|
||||
if r.Family == FamilyAny {
|
||||
return nil, fmt.Errorf("an icmp-type element without a family is unsupported")
|
||||
}
|
||||
isV6 := r.Family == IPv6
|
||||
num, ok := f.icmpTypeNumber(isV6, trimQuotes(strings.TrimPrefix(tokens[i], "name=")))
|
||||
if !ok {
|
||||
|
|
@ -649,7 +677,7 @@ func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) {
|
|||
return "", fmt.Errorf("firewalld rich rules cannot match a destination and source port together: %w", ErrUnsupportedSourcePort)
|
||||
}
|
||||
// An ICMP type only applies to an ICMP/ICMPv6 protocol; reject it paired with
|
||||
// anything else before we try to render an icmp-type element.
|
||||
// anything else before rendering an icmp-type element.
|
||||
if err := r.checkICMPType(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -719,7 +747,7 @@ func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) {
|
|||
// is no address to infer a family from — reject rather than emit a rule
|
||||
// firewalld refuses with MISSING_FAMILY.
|
||||
if _, _, err := net.ParseCIDR(dst); err != nil && net.ParseIP(dst) == nil {
|
||||
return "", fmt.Errorf("firewalld requires an explicit Family for a destination ipset match")
|
||||
return "", fmt.Errorf("firewalld requires an explicit Family for a destination ipset match: %w", ErrUnsupportedSet)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -868,11 +896,48 @@ func (f *FirewallD) zoneEntryEligible(r *Rule) bool {
|
|||
|
||||
// AddRule adds a filter rule to a zone, using a zone-level entry when the rule
|
||||
// fits one and a rich rule otherwise.
|
||||
// resolveDestSetFamily pins a family-agnostic rule whose destination names an
|
||||
// ipset to the set's own family: firewalld requires an explicit family for a
|
||||
// destination ipset match (unlike a source one, which it accepts familyless),
|
||||
// and an ipset is family-typed, so the rule could only ever match that family.
|
||||
// It is the firewalld analog of the nft and hook layers' set-family resolution.
|
||||
// Rules pinned already, or carrying an address that implies a family, pass
|
||||
// through unchanged.
|
||||
func (f *FirewallD) resolveDestSetFamily(ctx context.Context, r *Rule) (*Rule, error) {
|
||||
if r.Family != FamilyAny || !isSetRef(r.Destination) {
|
||||
return r, nil
|
||||
}
|
||||
if familyOfAddr(r.Source) != FamilyAny {
|
||||
return r, nil
|
||||
}
|
||||
_, name := splitAddrNeg(strings.TrimSpace(r.Destination))
|
||||
set, err := f.getAddressSet(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if set == nil {
|
||||
return nil, fmt.Errorf("rule references unknown address set %q", name)
|
||||
}
|
||||
fam := set.Family
|
||||
if fam != IPv6 {
|
||||
fam = IPv4
|
||||
}
|
||||
rc := *r
|
||||
rc.Family = fam
|
||||
return &rc, nil
|
||||
}
|
||||
|
||||
func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||
// firewalld has no output chain (Capabilities().Output is false); it models a
|
||||
// "destination" match rather than a true outbound direction. A both-directions
|
||||
// DirAny rule cannot be expressed, so it degrades to its input half.
|
||||
// DirAny rule cannot be expressed, so it degrades to its input half, and a
|
||||
// DirOutput rule is stored in the inbound frame (role-swapped) so both
|
||||
// authorings of the same match converge on one stored form and read back
|
||||
// identically — otherwise Sync would remove and re-add it forever.
|
||||
r = dirAnyInputFallback(r, f.Capabilities().Output)
|
||||
if r.Direction == DirOutput {
|
||||
r = r.canonicalMatch()
|
||||
}
|
||||
|
||||
// A firewalld zone port and rich rule each carry a single protocol, so a
|
||||
// TCPUDP rule has no single form; fan it into a tcp add and a udp add, each of
|
||||
|
|
@ -891,8 +956,14 @@ func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error
|
|||
return fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit)
|
||||
}
|
||||
|
||||
// A family-agnostic destination set reference is pinned to the set's own family.
|
||||
r, err := f.resolveDestSetFamily(ctx, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the zone.
|
||||
zoneName, err := f.resolveZoneName(ctx, zoneName)
|
||||
zoneName, err = f.resolveZoneName(ctx, zoneName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -970,18 +1041,27 @@ func (f *FirewallD) ignoreNotEnabled(err error) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager
|
||||
// interface onto firewalld's zones, rich rules, and ipsets.
|
||||
type FirewallD struct {
|
||||
Conn *firewalld.Conn
|
||||
// rulePrefix is accepted for a consistent constructor signature across
|
||||
// backends. firewalld organizes rules into zones rather than a private
|
||||
// namespace, so the prefix is not applied to individual rules.
|
||||
rulePrefix string
|
||||
// removeZoneEntry removes the zone-level entry — a zone port, source port, or
|
||||
// protocol — that a zone-entry-shaped rule maps to, reporting whether the rule's
|
||||
// shape matched one of those forms. The firewalld error (including NOT_ENABLED)
|
||||
// is returned uninterpreted; RemoveRule's zone-entry shortcut and its dual-stack
|
||||
// split share this so their form routing cannot drift apart.
|
||||
func (f *FirewallD) removeZoneEntry(ctx context.Context, zone *firewalld.PermZone, r *Rule) (bool, error) {
|
||||
switch {
|
||||
case r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts():
|
||||
return true, zone.RemovePort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()})
|
||||
case r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "":
|
||||
return true, zone.RemoveSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()})
|
||||
case r.Proto != ProtocolAny && r.Source == "" && !r.HasPorts() && !r.HasSourcePorts():
|
||||
return true, zone.RemoveProtocol(ctx, r.Proto.String())
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// isZoneSource reports whether a source string is a form firewalld stores as a
|
||||
// zone source: an IP, a CIDR, a MAC address, or an ipset reference (ipset:<name>).
|
||||
// RemoveRule uses it to decide whether a bare source can be cleared with
|
||||
// RemoveSource rather than falling through to the rich-rule path.
|
||||
func (f *FirewallD) isZoneSource(s string) bool {
|
||||
if _, _, err := net.ParseCIDR(s); err == nil {
|
||||
return true
|
||||
|
|
@ -998,12 +1078,17 @@ func (f *FirewallD) isZoneSource(s string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// RemoveRule uses it to decide whether a bare source can be cleared with
|
||||
// RemoveSource rather than falling through to the rich-rule path.
|
||||
// RemoveRule removes a filter rule from a zone, clearing every form it may be
|
||||
// stored in: the zone-level entry AddRule would have used and any rich rule
|
||||
// whose parsed form matches the target.
|
||||
func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||
// A DirAny rule degrades to its input half on firewalld (no output concept),
|
||||
// mirroring AddRule so a both-directions rule is found and removed as stored.
|
||||
// and a DirOutput target is role-swapped into the inbound frame, mirroring
|
||||
// AddRule so a rule is found and removed as stored.
|
||||
r = dirAnyInputFallback(r, f.Capabilities().Output)
|
||||
if r.Direction == DirOutput {
|
||||
r = r.canonicalMatch()
|
||||
}
|
||||
|
||||
// firewalld stores a TCPUDP rule as two concrete rows (a tcp entry and a udp
|
||||
// entry), never one shared row, so such a removal fans into a tcp remove and a
|
||||
|
|
@ -1030,45 +1115,23 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
|
|||
// so they are handled (ICMP) or rejected there rather than silently dropped by
|
||||
// the port/source shortcuts below. Logging and rate limiting also force the
|
||||
// rich-rule path. Each shortcut removes the element directly and relies on
|
||||
// firewalld's typed errors: a real error is returned, but NOT_ENABLED (the
|
||||
// element is absent as a zone entry) falls through to the rich-rule path in
|
||||
// case the rule was stored in that form out of band.
|
||||
// firewalld's typed errors: a real error is returned, while success and
|
||||
// NOT_ENABLED both continue to the rich-rule sweep — a rule present in both
|
||||
// storage forms (a zone entry plus an equivalent rich rule added out of band)
|
||||
// must be cleared from both in one call.
|
||||
if f.zoneEntryEligible(r) {
|
||||
// A single destination port (or contiguous range) maps to a zone port entry.
|
||||
// Mirror AddRule: key on HasPorts (so a single port in the Ports slice
|
||||
// matches) and require a concrete port protocol.
|
||||
if r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts() {
|
||||
port := firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()}
|
||||
if err := zone.RemovePort(ctx, port); !errors.Is(err, firewalld.ErrNotEnabled) {
|
||||
return err
|
||||
if matched, rerr := f.removeZoneEntry(ctx, zone, r); matched {
|
||||
if rerr != nil && !errors.Is(rerr, firewalld.ErrNotEnabled) {
|
||||
return rerr
|
||||
}
|
||||
} else if r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "" {
|
||||
// A single source port maps to a zone source-port entry.
|
||||
sp := firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()}
|
||||
if err := zone.RemoveSourcePort(ctx, sp); !errors.Is(err, firewalld.ErrNotEnabled) {
|
||||
return err
|
||||
}
|
||||
} else if f.sourceZoneShape(r) {
|
||||
} else if f.sourceZoneShape(r) && f.isZoneSource(r.Source) {
|
||||
// A bare source maps to a zone source. firewalld stores an IP/CIDR, a MAC
|
||||
// address, or an ipset reference (ipset:<name>) as a zone source, and
|
||||
// GetRules reports each verbatim, so removal must try RemoveSource for all
|
||||
// of them. A value that is not a zone source form falls through to the
|
||||
// rich-rule path below.
|
||||
// sourceZoneShape mirrors AddRule's ProtocolAny guard: a source+protocol
|
||||
// rule is a rich rule, so it must be removed as one rather than clearing
|
||||
// the bare zone source.
|
||||
if f.isZoneSource(r.Source) {
|
||||
if err := zone.RemoveSource(ctx, r.Source); !errors.Is(err, firewalld.ErrNotEnabled) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if r.Proto != ProtocolAny && r.Source == "" && !r.HasPorts() && !r.HasSourcePorts() {
|
||||
// A bare protocol allow (no port or address) maps to a zone protocol
|
||||
// entry (firewall-cmd --add-protocol), which GetRules now surfaces.
|
||||
// Mirror the port/source shortcuts so such a foreign entry is removable;
|
||||
// a NOT_ENABLED result falls through to the rich-rule path for a rule the
|
||||
// library stored as a rich-rule protocol match instead.
|
||||
if err := zone.RemoveProtocol(ctx, r.Proto.String()); !errors.Is(err, firewalld.ErrNotEnabled) {
|
||||
// of them. A value that is not a zone source form is left to the rich-rule
|
||||
// path below; sourceZoneShape mirrors AddRule's ProtocolAny guard, since a
|
||||
// source+protocol rule is stored as a rich rule.
|
||||
if err := zone.RemoveSource(ctx, r.Source); err != nil && !errors.Is(err, firewalld.ErrNotEnabled) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -1088,19 +1151,7 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
|
|||
rAny := *r
|
||||
rAny.Family = FamilyAny
|
||||
if f.zoneEntryEligible(&rAny) && r.Source == "" {
|
||||
var removeErr error
|
||||
matched := true
|
||||
switch {
|
||||
case r.HasPorts() && r.Proto.HasPorts() && !r.HasSourcePorts():
|
||||
removeErr = zone.RemovePort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()})
|
||||
case r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts():
|
||||
removeErr = zone.RemoveSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()})
|
||||
case r.Proto != ProtocolAny && !r.HasPorts() && !r.HasSourcePorts():
|
||||
removeErr = zone.RemoveProtocol(ctx, r.Proto.String())
|
||||
default:
|
||||
matched = false
|
||||
}
|
||||
if matched {
|
||||
if matched, removeErr := f.removeZoneEntry(ctx, zone, r); matched {
|
||||
if removeErr == nil {
|
||||
if s := splitDualRow(&rAny, r); s != nil {
|
||||
return f.AddRule(ctx, zoneName, s)
|
||||
|
|
@ -1128,7 +1179,7 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
|
|||
if r.Proto == ICMPv6 {
|
||||
proto = "ipv6-icmp"
|
||||
}
|
||||
if err := zone.RemoveProtocol(ctx, proto); !errors.Is(err, firewalld.ErrNotEnabled) {
|
||||
if err := zone.RemoveProtocol(ctx, proto); err != nil && !errors.Is(err, firewalld.ErrNotEnabled) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -1187,7 +1238,7 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Get the zone settings so we can read forward ports and masquerade.
|
||||
// Read the zone settings for forward ports and masquerade.
|
||||
settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -1195,32 +1246,12 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
|
|||
|
||||
// Each forward port maps to a DNAT (when it targets another address) or a
|
||||
// Redirect (same host, port only). firewalld's model carries no family, so
|
||||
// these are returned as FamilyAny.
|
||||
// these are returned as FamilyAny. An entry the model cannot hold stays
|
||||
// unmanaged (see modeledForwardPort).
|
||||
for _, fp := range settings.ForwardPorts {
|
||||
pr, perr := ParsePortRange(fp.Port)
|
||||
if perr != nil {
|
||||
continue
|
||||
if rule := f.modeledForwardPort(fp); rule != nil {
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
rule := &NATRule{Proto: GetProtocol(fp.Protocol)}
|
||||
if pr.Start == pr.End {
|
||||
rule.Port = pr.Start
|
||||
} else {
|
||||
rule.Ports = []PortRange{pr}
|
||||
}
|
||||
if fp.ToPort != "" {
|
||||
tp, terr := strconv.ParseUint(fp.ToPort, 10, 16)
|
||||
if terr != nil {
|
||||
continue
|
||||
}
|
||||
rule.ToPort = uint16(tp)
|
||||
}
|
||||
rule.ToAddress = fp.ToAddr
|
||||
if fp.ToAddr != "" {
|
||||
rule.Kind = DNAT
|
||||
} else {
|
||||
rule.Kind = Redirect
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
// Zone masquerade maps to a single Masquerade rule.
|
||||
|
|
@ -1237,6 +1268,43 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
|
|||
return rules, nil
|
||||
}
|
||||
|
||||
// modeledForwardPort decodes a zone forward-port entry into the NATRule
|
||||
// GetNATRules reports, or nil for an entry the model cannot hold: an
|
||||
// unparseable port, a protocol the write side rejects (sctp/dccp — forwardPort
|
||||
// takes tcp/udp only, so such a rule could never be removed or re-added), or a
|
||||
// target-port range. An unmodeled entry stays unmanaged, and Restore leaves it
|
||||
// in place for the same reason.
|
||||
func (f *FirewallD) modeledForwardPort(fp firewalld.ForwardPort) *NATRule {
|
||||
pr, perr := ParsePortRange(fp.Port)
|
||||
if perr != nil {
|
||||
return nil
|
||||
}
|
||||
proto := GetProtocol(fp.Protocol)
|
||||
if proto != TCP && proto != UDP {
|
||||
return nil
|
||||
}
|
||||
rule := &NATRule{Proto: proto}
|
||||
if pr.Start == pr.End {
|
||||
rule.Port = pr.Start
|
||||
} else {
|
||||
rule.Ports = []PortRange{pr}
|
||||
}
|
||||
if fp.ToPort != "" {
|
||||
tp, terr := strconv.ParseUint(fp.ToPort, 10, 16)
|
||||
if terr != nil {
|
||||
return nil
|
||||
}
|
||||
rule.ToPort = uint16(tp)
|
||||
}
|
||||
rule.ToAddress = fp.ToAddr
|
||||
if fp.ToAddr != "" {
|
||||
rule.Kind = DNAT
|
||||
} else {
|
||||
rule.Kind = Redirect
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's
|
||||
// per-zone port-forward API expects (port, protocol, toport, toaddr). That API
|
||||
// carries only these four fields, so any source, destination or interface match —
|
||||
|
|
@ -1246,20 +1314,20 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
|
|||
// would not round-trip, so it is intentionally not emitted here.)
|
||||
func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) {
|
||||
if r.Proto != TCP && r.Proto != UDP {
|
||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol")
|
||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol: %w", ErrUnsupportedNAT)
|
||||
}
|
||||
if !r.HasPorts() {
|
||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port")
|
||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port: %w", ErrUnsupportedNAT)
|
||||
}
|
||||
specs := r.PortSpecs()
|
||||
if len(specs) > 1 {
|
||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward")
|
||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward: %w", ErrUnsupportedNAT)
|
||||
}
|
||||
if r.Interface != "" {
|
||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface")
|
||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface: %w", ErrUnsupportedNAT)
|
||||
}
|
||||
if r.Source != "" || r.Destination != "" {
|
||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching")
|
||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching: %w", ErrUnsupportedNAT)
|
||||
}
|
||||
fp := firewalld.ForwardPort{
|
||||
Port: specs[0].String(),
|
||||
|
|
@ -1401,7 +1469,10 @@ func (f *FirewallD) SetDefaultPolicy(ctx context.Context, zoneName string, polic
|
|||
|
||||
// --- address sets (firewalld ipsets) ----------------------------------------
|
||||
|
||||
// getAddressSet reads a single firewalld ipset, or nil if it does not exist.
|
||||
// getAddressSet reads a single firewalld ipset, or nil if it does not exist or
|
||||
// has a type the model cannot hold. Only hash:ip and hash:net map onto SetType;
|
||||
// coercing another type (hash:mac, hash:ip,port, ...) would let a Backup/Restore
|
||||
// rewrite the set as a bare hash:ip and break it, so those stay unmanaged.
|
||||
func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
||||
settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx)
|
||||
if errors.Is(err, firewalld.ErrInvalidIPSet) {
|
||||
|
|
@ -1410,9 +1481,14 @@ func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := &AddressSet{Name: name, Entries: settings.Entries, Type: SetHashIP}
|
||||
if settings.Type == "hash:net" {
|
||||
set := &AddressSet{Name: name, Entries: settings.Entries}
|
||||
switch settings.Type {
|
||||
case "hash:ip":
|
||||
set.Type = SetHashIP
|
||||
case "hash:net":
|
||||
set.Type = SetHashNet
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
switch settings.Options["family"] {
|
||||
case "inet6":
|
||||
|
|
@ -1568,13 +1644,22 @@ func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup
|
|||
return err
|
||||
}
|
||||
|
||||
// Remove all ports, sources, and rich rules that we can decode as managed.
|
||||
// Remove the zone entries and rich rules the model manages. Each removal
|
||||
// mirrors the guard its read side applies, so an entry GetRules skips as
|
||||
// unmodeled (a dccp port, an unrecognized protocol, an unparseable rich rule)
|
||||
// is preserved rather than destroyed by a Backup that never captured it.
|
||||
for _, port := range settings.Ports {
|
||||
if len(f.zonePortRules([]firewalld.Port{port}, false)) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := zone.RemovePort(ctx, port); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, sp := range settings.SourcePorts {
|
||||
if len(f.zonePortRules([]firewalld.Port{sp}, true)) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := zone.RemoveSourcePort(ctx, sp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -1585,18 +1670,28 @@ func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup
|
|||
}
|
||||
}
|
||||
for _, proto := range settings.Protocols {
|
||||
if GetProtocol(proto) == ProtocolAny {
|
||||
continue
|
||||
}
|
||||
if err := zone.RemoveProtocol(ctx, proto); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, richRule := range settings.RichRules {
|
||||
if _, perr := f.UnmarshalRichRule(richRule); perr != nil {
|
||||
continue
|
||||
}
|
||||
if err := zone.RemoveRichRule(ctx, richRule); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Remove NAT rules.
|
||||
// Remove the modeled NAT entries, preserving forward ports the model cannot
|
||||
// hold for the same reason.
|
||||
for _, fp := range settings.ForwardPorts {
|
||||
if f.modeledForwardPort(fp) == nil {
|
||||
continue
|
||||
}
|
||||
if err := zone.RemoveForwardPort(ctx, fp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,17 +35,24 @@ func TestFirewallDZonePortRulesSkipsUnmodeledProto(t *testing.T) {
|
|||
func TestFirewallDRichRules(t *testing.T) {
|
||||
fw := new(FirewallD)
|
||||
|
||||
// Parse a rule that is expected to parse right.
|
||||
rule, err := fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log limit value="1/m" audit accept`)
|
||||
// Parse a rule that is expected to parse right: an action-scoped limit
|
||||
// (after accept) is the traffic limit RateLimit models.
|
||||
rule, err := fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log audit accept limit value="1/m"`)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Re-encode the rule which should result in expected rich rule. The log and
|
||||
// rate limit now round-trip (audit is still not modeled and is dropped).
|
||||
// rate limit round-trip (audit is still not modeled and is dropped).
|
||||
richRule, err := fw.MarshalRichRule(rule)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `rule family="ipv4" source address="192.168.0.0/24" port port="23" protocol="udp" log level="info" accept limit value="1/m"`, richRule,
|
||||
"the rich rule did not encode as expected")
|
||||
|
||||
// A log-scoped limit (directly after log) throttles only the logging, which
|
||||
// the model cannot hold; reading it as a traffic limit would let a rewrite
|
||||
// change what the rule enforces, so it must stay unmodeled.
|
||||
_, err = fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log limit value="1/m" audit accept`)
|
||||
require.Error(t, err, "a log-scoped limit must be rejected")
|
||||
|
||||
// Try encoding a bunch of invalid rules.
|
||||
invalidRules := []string{
|
||||
`rule family=ipv4 source address="192.168.0.0/24" service name=ftp reject`,
|
||||
|
|
@ -194,8 +201,7 @@ func TestFirewallDICMPType(t *testing.T) {
|
|||
// matching a destination ipset must marshal to `destination ipset="..."` and
|
||||
// round-trip, rather than being rejected or read back as invisible. Unlike a
|
||||
// source ipset, firewalld rejects a familyless destination ipset with
|
||||
// MISSING_FAMILY (verified against the real firewalld.core.rich parser), so the
|
||||
// caller must supply a concrete Family.
|
||||
// MISSING_FAMILY, so the caller must supply a concrete Family.
|
||||
func TestFirewallDDestinationIPSet(t *testing.T) {
|
||||
fw := new(FirewallD)
|
||||
|
||||
|
|
@ -207,7 +213,8 @@ func TestFirewallDDestinationIPSet(t *testing.T) {
|
|||
parsed, err := fw.UnmarshalRichRule(got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "myset", parsed.Destination)
|
||||
require.True(t, parsed.IsOutput(), "a destination match is an output rule")
|
||||
require.False(t, parsed.IsOutput(),
|
||||
"a destination element is an inbound-frame match, not a direction signal")
|
||||
|
||||
// A negated destination ipset round-trips too.
|
||||
neg := &Rule{Family: IPv4, Destination: "!badset", Proto: TCP, Port: 443, Action: Drop}
|
||||
|
|
@ -460,12 +467,10 @@ func TestFirewalldZoneEntryEligibleRange(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestFirewallDBareICMPFamilyPinned guards that a bare (untyped) ICMP/ICMPv6 rule
|
||||
// is left unqualified by family and still round-trips correctly. Live testing
|
||||
// against firewalld 2.4.3 confirmed it accepts a familyless rich rule carrying
|
||||
// `protocol value="ipv6-icmp"` just like any other protocol (its rich.py check()
|
||||
// only requires a family for a source/destination address, never for a bare
|
||||
// protocol match), and the protocol value string alone ("icmp" vs "ipv6-icmp")
|
||||
// tells the read path ICMP from ICMPv6 without needing a family qualifier.
|
||||
// is left unqualified by family and still round-trips correctly. firewalld only
|
||||
// requires a family for a source/destination address, never for a bare protocol
|
||||
// match, and the protocol value string alone ("icmp" vs "ipv6-icmp") tells the
|
||||
// read path ICMP from ICMPv6.
|
||||
func TestFirewallDBareICMPFamilyPinned(t *testing.T) {
|
||||
fw := new(FirewallD)
|
||||
|
||||
|
|
@ -595,3 +600,26 @@ func TestFirewalldICMPv6DestinationUnreachable(t *testing.T) {
|
|||
require.NoError(t, err, "an icmpv6 destination-unreachable rich rule must parse")
|
||||
require.True(t, r.Equal(back, false), "icmpv6 destination-unreachable must round-trip: got %+v", back)
|
||||
}
|
||||
|
||||
// modeledForwardPort must reject the forward-port shapes the write side
|
||||
// (forwardPort) cannot re-create — otherwise GetNATRules would surface entries
|
||||
// that can never be removed or restored, and Restore would destroy them.
|
||||
func TestFirewallDModeledForwardPort(t *testing.T) {
|
||||
fw := new(FirewallD)
|
||||
|
||||
dnat := fw.modeledForwardPort(firewalld.ForwardPort{Port: "8080", Protocol: "tcp", ToPort: "80", ToAddr: "10.0.0.5"})
|
||||
require.NotNil(t, dnat)
|
||||
require.Equal(t, DNAT, dnat.Kind)
|
||||
require.EqualValues(t, 8080, dnat.Port)
|
||||
require.EqualValues(t, 80, dnat.ToPort)
|
||||
|
||||
redirect := fw.modeledForwardPort(firewalld.ForwardPort{Port: "8443", Protocol: "udp", ToPort: "443"})
|
||||
require.NotNil(t, redirect)
|
||||
require.Equal(t, Redirect, redirect.Kind)
|
||||
|
||||
// Shapes forwardPort rejects stay unmodeled: a non-tcp/udp protocol and a
|
||||
// target-port range.
|
||||
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "9999", Protocol: "sctp", ToPort: "80", ToAddr: "10.0.0.5"}))
|
||||
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "9999", Protocol: "dccp"}))
|
||||
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "8080", Protocol: "tcp", ToPort: "8080-8090", ToAddr: "10.0.0.5"}))
|
||||
}
|
||||
|
|
|
|||
4
go.mod
4
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
|
||||
|
|
|
|||
494
hooks_linux.go
494
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ func probeDebianLayout(root string) (l iptLayout, ok bool) {
|
|||
|
||||
// probeRHELLayout reports the RHEL/iptables-services layout
|
||||
// (/etc/sysconfig/iptables and ip6tables, restored by the iptables and
|
||||
// ip6tables services) if its save files are both present under root. It does
|
||||
// ip6tables services) if its save files are both present under root. The
|
||||
// caller must have already confirmed the RHEL v4 path exists before treating
|
||||
// an incomplete pair (ok=false) as fatal rather than falling through to
|
||||
// another layout. root is prepended to both paths so tests can point the
|
||||
|
|
@ -111,9 +111,9 @@ func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) {
|
|||
|
||||
// Prefer the RHEL layout when its v4 save file is present. A v4 file with
|
||||
// no v6 partner is still a fatal IPTablesNoSave (not a signal to try the
|
||||
// Debian layout) — we do not know what may be used for the firewall in that
|
||||
// case. Only when the RHEL v4 path is entirely absent do we probe the
|
||||
// Debian iptables-persistent layout instead.
|
||||
// Debian layout), since what manages the firewall in that case is unknown.
|
||||
// Only when the RHEL v4 path is entirely absent is the Debian
|
||||
// iptables-persistent layout probed instead.
|
||||
var layout iptLayout
|
||||
if _, err := os.Stat("/etc/sysconfig/iptables"); err == nil {
|
||||
l, ok := probeRHELLayout("")
|
||||
|
|
@ -137,8 +137,8 @@ func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) {
|
|||
return nil, errors.New(IPTablesNoService)
|
||||
}
|
||||
|
||||
// If ip6tables service is missing, we do not want to modify iptables
|
||||
// as we do not know what may be used for the firewall. Skip the redundant
|
||||
// A missing ip6tables service means something unknown manages the v6
|
||||
// firewall, so iptables must not be modified either. Skip the redundant
|
||||
// check when it is the same service already confirmed enabled above (the
|
||||
// Debian layout uses one service for both families).
|
||||
ipt.IP6Service = layout.ip6Service
|
||||
|
|
@ -164,21 +164,25 @@ func (f *IPTables) Type() string {
|
|||
// Capabilities returns the set of features this backend can express.
|
||||
func (f *IPTables) Capabilities() Capabilities {
|
||||
return Capabilities{
|
||||
Output: true,
|
||||
Forward: true,
|
||||
ICMPv6: true,
|
||||
PortList: true,
|
||||
ConnState: true,
|
||||
InterfaceMatch: true,
|
||||
Logging: true,
|
||||
RateLimit: true,
|
||||
ConnLimit: true,
|
||||
NAT: true,
|
||||
RuleOrdering: true,
|
||||
DefaultPolicy: true,
|
||||
RuleCounters: true,
|
||||
AddressSets: true,
|
||||
Comments: true,
|
||||
Output: true,
|
||||
Forward: true,
|
||||
ICMPv6: true,
|
||||
PortList: true,
|
||||
PortPair: true,
|
||||
ConnState: true,
|
||||
InterfaceMatch: true,
|
||||
Logging: true,
|
||||
RateLimit: true,
|
||||
ConnLimit: true,
|
||||
NAT: true,
|
||||
RuleOrdering: true,
|
||||
DefaultPolicy: true,
|
||||
RuleCounters: true,
|
||||
AddressSets: true,
|
||||
Comments: true,
|
||||
Negation: true,
|
||||
RejectAction: true,
|
||||
FamilyWithoutAddress: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -306,7 +310,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
|||
// Continue so the negation is not cleared before the match token is read.
|
||||
continue
|
||||
case "-p", "--protocol":
|
||||
// We do not support negation on this parameter.
|
||||
// Negation is unsupported on this parameter.
|
||||
if not {
|
||||
return nil, fmt.Errorf("negation is defined for protocol, which our limited rule structure does not support")
|
||||
}
|
||||
|
|
@ -328,7 +332,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
|||
return nil, fmt.Errorf("invalid source parameter")
|
||||
}
|
||||
|
||||
// Confirm we can parse the address.
|
||||
// Confirm the address parses.
|
||||
_, _, err := net.ParseCIDR(tokens[i])
|
||||
ip := net.ParseIP(tokens[i])
|
||||
if err != nil && ip == nil {
|
||||
|
|
@ -347,7 +351,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
|||
return nil, fmt.Errorf("invalid destination parameter")
|
||||
}
|
||||
|
||||
// Confirm we can parse the address.
|
||||
// Confirm the address parses.
|
||||
_, _, err := net.ParseCIDR(tokens[i])
|
||||
ip := net.ParseIP(tokens[i])
|
||||
if err != nil && ip == nil {
|
||||
|
|
@ -434,7 +438,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
|||
}
|
||||
r.OutInterface = tokens[i]
|
||||
case "-j", "--jump":
|
||||
// We do not support negation on this parameter.
|
||||
// Negation is unsupported on this parameter.
|
||||
if not {
|
||||
return nil, fmt.Errorf("negation is defined for jump, which our limited rule structure does not support")
|
||||
}
|
||||
|
|
@ -450,6 +454,11 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
|||
r.Action = Drop
|
||||
case "REJECT":
|
||||
r.Action = Reject
|
||||
// The Rule model carries no reject type, so a `--reject-with` detail is
|
||||
// consumed and a rewrite re-emits a plain REJECT (iptables' default
|
||||
// icmp-port-unreachable). Rejecting the line instead would leave common
|
||||
// stock rules (RHEL's icmp-host-prohibited REJECT) foreign and
|
||||
// unmanageable, which costs more than the reject-code drift.
|
||||
if i+2 < len(tokens) {
|
||||
if tokens[i+1] == "--reject-with" {
|
||||
i += 2
|
||||
|
|
@ -476,7 +485,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
|||
return nil, fmt.Errorf("unsupported jump option: %s", tokens[i])
|
||||
}
|
||||
case "-m", "--match":
|
||||
// We do not support negation on this parameter (the set match negates
|
||||
// Negation is unsupported on this parameter (the set match negates
|
||||
// internally, after `-m set`, so it is handled inside its case).
|
||||
if not {
|
||||
return nil, fmt.Errorf("negation is defined for match, which our limited rule structure does not support")
|
||||
|
|
@ -627,12 +636,15 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
|||
r.ICMPType = Ptr(n)
|
||||
}
|
||||
case "multiport":
|
||||
// -m multiport --dports/--sports 80,443,1000:2000 (also --ports).
|
||||
// -m multiport --dports/--sports 80,443,1000:2000. `--ports`/`--port`
|
||||
// means source OR destination port, which the model cannot hold —
|
||||
// mapping it onto one side would silently drop the other half on a
|
||||
// re-marshal — so such a line stays foreign.
|
||||
if i+2 >= len(tokens) {
|
||||
return nil, fmt.Errorf("invalid multiport match")
|
||||
}
|
||||
switch tokens[i+1] {
|
||||
case "--dports", "--dport", "--sports", "--sport", "--ports", "--port":
|
||||
case "--dports", "--dport", "--sports", "--sport":
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
|
||||
}
|
||||
|
|
@ -656,7 +668,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
|||
}
|
||||
}
|
||||
case "tcp":
|
||||
// Invalid protocol define.
|
||||
// Reject an unknown protocol token.
|
||||
if r.Proto == UDP {
|
||||
return nil, fmt.Errorf("specifying TCP options for UDP")
|
||||
}
|
||||
|
|
@ -713,7 +725,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
|||
case "udp", "sctp":
|
||||
// SCTP carries ports like UDP and iptables-save spells its port
|
||||
// match module `-m sctp`, so it shares this branch.
|
||||
// Invalid protocol define.
|
||||
// Reject an unknown protocol token.
|
||||
if r.Proto == TCP {
|
||||
return nil, fmt.Errorf("specifying UDP options for TCP")
|
||||
}
|
||||
|
|
@ -775,7 +787,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
|||
return nil, fmt.Errorf("unsupported option: %s", tokens[i])
|
||||
}
|
||||
|
||||
// As rule has been parsed, we may now mark not as false.
|
||||
// The token consumed any pending negation; clear it for the next one.
|
||||
not = false
|
||||
}
|
||||
|
||||
|
|
@ -861,7 +873,11 @@ func coalesceLoggedRules(rules []*Rule) []*Rule {
|
|||
|
||||
// parseFilterFile reads a family's iptables-save file and returns its filter
|
||||
// rules as logical rules, coalescing each LOG line with the action line that
|
||||
// follows it. Lines that do not parse (nat rules, custom chains) are skipped.
|
||||
// follows it. Lines that do not parse (nat rules, custom chains, unmodeled
|
||||
// foreign matches) are skipped, but an unmodeled line still holds its physical
|
||||
// slot during coalescing: a LOG line and an action line separated by a foreign
|
||||
// line are NOT one logged rule — extractRuleLines and logicalStarts see them
|
||||
// apart, so pairing them here would report a rule no removal could ever find.
|
||||
func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) {
|
||||
fd, err := os.Open(path)
|
||||
if err != nil {
|
||||
|
|
@ -895,6 +911,10 @@ func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error)
|
|||
}
|
||||
r, err := f.UnmarshalRule(line, family)
|
||||
if err != nil {
|
||||
// Keep the unmodeled line's slot so LOG pairing cannot reach across it.
|
||||
if strings.HasPrefix(f.ruleLineBody(line), "-A ") {
|
||||
perLine = append(perLine, nil)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// UnmarshalRule already stripped the prefix from the comment and
|
||||
|
|
@ -905,9 +925,23 @@ func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error)
|
|||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Return the coalesced rules; GetRules assigns Number once both save files are
|
||||
// read (the other callers use the result only for dedup and never read Number).
|
||||
return coalesceLoggedRules(perLine), nil
|
||||
// Coalesce LOG pairs positionally — exactly as logicalStarts numbers them —
|
||||
// then compact the unmodeled slots away; GetRules assigns Number once both
|
||||
// save files are read (the other callers use the result only for dedup and
|
||||
// never read Number).
|
||||
starts := f.logicalStarts(perLine)
|
||||
out := make([]*Rule, 0, len(perLine))
|
||||
for i, r := range perLine {
|
||||
if r == nil || starts[i] == 0 {
|
||||
continue
|
||||
}
|
||||
if r.Action == ActionInvalid && r.Log {
|
||||
out = append(out, mergeLogPair(r, perLine[i+1]))
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRules returns the existing filter rules from the zone.
|
||||
|
|
@ -931,11 +965,62 @@ func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule
|
|||
numberByDirection(v4)
|
||||
numberByDirection(v6)
|
||||
|
||||
// The save files carry no packet/byte counters — the kernel does — so merge
|
||||
// them from the live ruleset (RuleCounters).
|
||||
f.mergeLiveCounters(ctx, v4, IPv4)
|
||||
f.mergeLiveCounters(ctx, v6, IPv6)
|
||||
|
||||
rules = append(rules, v4...)
|
||||
rules = append(rules, v6...)
|
||||
return
|
||||
}
|
||||
|
||||
// mergeLiveCounters copies the kernel's packet/byte counters onto the
|
||||
// file-parsed rules, matching `iptables-save -c` lines to file rules by rule
|
||||
// identity. It is best-effort: a missing save binary, a file edit not yet
|
||||
// activated by Reload, or a rule absent from the kernel simply leaves that
|
||||
// rule's counters zero. Counters are informational (RuleCounters) and never
|
||||
// part of rule identity.
|
||||
func (f *IPTables) mergeLiveCounters(ctx context.Context, rules []*Rule, fam Family) {
|
||||
cmd := "iptables-save"
|
||||
if fam == IPv6 {
|
||||
cmd = "ip6tables-save"
|
||||
}
|
||||
out, err := runCommand(ctx, cmd, "-c", "-t", "filter")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var live []*Rule
|
||||
for _, line := range out {
|
||||
line = strings.TrimSpace(line)
|
||||
// Rule lines carry a leading [pkts:bytes] token; policy and table
|
||||
// headers do not.
|
||||
if !strings.HasPrefix(line, "[") {
|
||||
continue
|
||||
}
|
||||
r, uerr := unmarshalIPTablesRule(line, fam)
|
||||
if uerr != nil {
|
||||
continue
|
||||
}
|
||||
live = append(live, r)
|
||||
}
|
||||
// Consume each live row at most once so duplicate rules keep distinct counts.
|
||||
used := make([]bool, len(live))
|
||||
for _, r := range rules {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
for i, l := range live {
|
||||
if used[i] || !l.Equal(r, true) {
|
||||
continue
|
||||
}
|
||||
r.Packets, r.Bytes = l.Packets, l.Bytes
|
||||
used[i] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyRuleFiles runs prepare against each family file the rule applies to,
|
||||
// staging all temp files first and committing them only once every file has
|
||||
// been prepared successfully. A FamilyAny rule touches both the IPv4 and IPv6
|
||||
|
|
@ -1082,11 +1167,11 @@ func iptChainForDirection(d Direction) string {
|
|||
|
||||
// logicalStarts maps each entry of rules (the parsed `-A` lines of one chain,
|
||||
// in file order) to the 1-based logical-rule position it begins, or 0 when it is
|
||||
// not a logical-rule start — an action line coalesced into a preceding LOG line,
|
||||
// or a dropped orphan LOG line. It mirrors coalesceLoggedRules exactly so an
|
||||
// insert/move position aligns with the per-chain numbering GetRules reports: a
|
||||
// LOG line paired with its action is one logical rule beginning at the LOG line,
|
||||
// while an orphan LOG line (no matching action after it) begins none. The
|
||||
// not a logical-rule start — an unmodeled (nil) foreign line, an action line
|
||||
// coalesced into a preceding LOG line, or a dropped orphan LOG line. It mirrors
|
||||
// GetRules' numbering exactly — Rule.Number promises to mirror the InsertRule/
|
||||
// MoveRule position argument, and GetRules skips lines it cannot parse — so a
|
||||
// caller position never lands relative to a rule GetRules did not report. The
|
||||
// returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and
|
||||
// prepareMoveRuleFile can translate a caller position into a physical line.
|
||||
func (f *IPTables) logicalStarts(rules []*Rule) []int {
|
||||
|
|
@ -1094,7 +1179,12 @@ func (f *IPTables) logicalStarts(rules []*Rule) []int {
|
|||
pos := 0
|
||||
for i := 0; i < len(rules); i++ {
|
||||
cur := rules[i]
|
||||
if cur != nil && cur.Action == ActionInvalid && cur.Log {
|
||||
// An unmodeled foreign line is invisible to GetRules, so it begins no
|
||||
// logical rule; it stays pinned to the rule that follows it.
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
if cur.Action == ActionInvalid && cur.Log {
|
||||
var next *Rule
|
||||
if i+1 < len(rules) {
|
||||
next = rules[i+1]
|
||||
|
|
@ -1234,6 +1324,7 @@ func (f *IPTables) stateValue(s ConnState) string {
|
|||
// marshalMatches builds the iptables-save match tokens for a rule (everything
|
||||
// up to but not including the `-j <target>`), including any rate/connection
|
||||
// limit and the identifying comment. MarshalRule and the LOG-line encoder share
|
||||
// it so a logged rule's two lines carry identical match tokens.
|
||||
func (f *IPTables) marshalMatches(r *Rule) ([]string, error) {
|
||||
// A TCPUDP rule has no single-line iptables form; it must be fanned out into a
|
||||
// tcp row and a udp row before reaching this row-level marshaller. Assert the
|
||||
|
|
@ -1435,11 +1526,12 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
|
|||
return nil, err
|
||||
}
|
||||
|
||||
fd, err := os.Open(filePath)
|
||||
// Read the file once; the same lines feed both the position precomputation
|
||||
// and the rewrite pass.
|
||||
allLines, err := f.readAllLines(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = fd.Close() }()
|
||||
|
||||
af, err := newAtomicFile(filePath, 0644)
|
||||
if err != nil {
|
||||
|
|
@ -1457,15 +1549,9 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
|
|||
// Indexing this as the write pass scans keeps the insert aligned with the
|
||||
// position GetRules reports and never splits a logged rule's two lines or a
|
||||
// fanned-out tcp/udp pair.
|
||||
allLines, err := f.readAllLines(filePath)
|
||||
if err != nil {
|
||||
af.Abort()
|
||||
return nil, err
|
||||
}
|
||||
chainStarts := f.chainStarts(allLines, expectedChain)
|
||||
chainIdx := 0
|
||||
|
||||
scanner := bufio.NewScanner(fd)
|
||||
filterFound := false
|
||||
writtenRule := false
|
||||
writeRule := func() {
|
||||
|
|
@ -1475,8 +1561,8 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
|
|||
writtenRule = true
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
for _, raw := range allLines {
|
||||
line := strings.TrimSpace(raw)
|
||||
|
||||
if !filterFound {
|
||||
if line == "*filter" {
|
||||
|
|
@ -1504,16 +1590,9 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
|
|||
_, _ = fmt.Fprintln(af, line)
|
||||
}
|
||||
|
||||
// A read error means the staged file is truncated; discard it rather than
|
||||
// installing a partial ruleset.
|
||||
if err := scanner.Err(); err != nil {
|
||||
af.Abort()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !writtenRule {
|
||||
af.Abort()
|
||||
return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file")
|
||||
return nil, fmt.Errorf("failed to write the new rule to the iptables-save file")
|
||||
}
|
||||
|
||||
return af, nil
|
||||
|
|
@ -1706,7 +1785,7 @@ func (f *IPTables) prepareMoveRuleFile(filePath string, r *Rule, position int) (
|
|||
}
|
||||
|
||||
if !inserted {
|
||||
return nil, fmt.Errorf("we were not able to move the rule in the iptables-save file")
|
||||
return nil, fmt.Errorf("failed to move the rule in the iptables-save file")
|
||||
}
|
||||
|
||||
return f.stageLines(filePath, out)
|
||||
|
|
@ -1748,7 +1827,12 @@ func (f *IPTables) parseNATTarget(tok string) (addr string, port uint16) {
|
|||
return tok, 0
|
||||
}
|
||||
|
||||
// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING.
|
||||
// UnmarshalNATRule decodes one iptables-save nat line (-A PREROUTING / -A
|
||||
// POSTROUTING ...) into a NATRule. A line the model cannot hold faithfully — a
|
||||
// negated interface/protocol/port match, an unknown protocol, an unsupported
|
||||
// jump, or another chain — is rejected so the raw line stays foreign and is
|
||||
// preserved verbatim; an OUTPUT-chain DNAT, for example, has no distinct model
|
||||
// here and would otherwise be relocated to PREROUTING on Restore.
|
||||
func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error) {
|
||||
tokens, err := shlex.Split(spec, true)
|
||||
if err != nil {
|
||||
|
|
@ -1819,18 +1903,37 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error
|
|||
r.Destination = tokens[i]
|
||||
}
|
||||
case "-i", "--in-interface", "-o", "--out-interface":
|
||||
// Interface, protocol and port carry no negated form in the model, so
|
||||
// a `!` here must reject the line — reading `! -o docker0` as a plain
|
||||
// interface match would invert the rule's meaning (Docker's stock
|
||||
// masquerade rule is exactly this shape).
|
||||
if not {
|
||||
return nil, fmt.Errorf("negated interface match is not modeled")
|
||||
}
|
||||
i++
|
||||
if i >= len(tokens) {
|
||||
return nil, fmt.Errorf("invalid interface parameter")
|
||||
}
|
||||
r.Interface = tokens[i]
|
||||
case "-p", "--protocol":
|
||||
if not {
|
||||
return nil, fmt.Errorf("negated protocol match is not modeled")
|
||||
}
|
||||
i++
|
||||
if i >= len(tokens) {
|
||||
return nil, fmt.Errorf("invalid protocol parameter")
|
||||
}
|
||||
r.Proto = GetProtocol(tokens[i])
|
||||
proto := GetProtocol(tokens[i])
|
||||
if proto == ProtocolAny {
|
||||
// An unknown protocol token must not silently widen to a
|
||||
// match-every-protocol rule a Restore would re-marshal without -p.
|
||||
return nil, fmt.Errorf("unsupported protocol: %s", tokens[i])
|
||||
}
|
||||
r.Proto = proto
|
||||
case "--dport", "--destination-port":
|
||||
if not {
|
||||
return nil, fmt.Errorf("negated port match is not modeled")
|
||||
}
|
||||
i++
|
||||
if i >= len(tokens) {
|
||||
return nil, fmt.Errorf("invalid dport parameter")
|
||||
|
|
@ -1845,6 +1948,9 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error
|
|||
r.Ports = []PortRange{pr}
|
||||
}
|
||||
case "-m", "--match":
|
||||
if not {
|
||||
return nil, fmt.Errorf("negated match is not modeled")
|
||||
}
|
||||
i++
|
||||
if i >= len(tokens) {
|
||||
return nil, fmt.Errorf("invalid match parameter")
|
||||
|
|
@ -1904,8 +2010,11 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error
|
|||
return nil, fmt.Errorf("invalid multiport match")
|
||||
}
|
||||
switch tokens[i+1] {
|
||||
case "--dports", "--dport", "--ports", "--port":
|
||||
case "--dports", "--dport":
|
||||
default:
|
||||
// `--ports`/`--port` means source OR destination port; mapping it
|
||||
// onto the destination fields would silently drop the source half
|
||||
// on a re-marshal, so the line stays foreign.
|
||||
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
|
||||
}
|
||||
i += 2
|
||||
|
|
@ -2184,7 +2293,9 @@ func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) e
|
|||
return f.writeAllLines(path, updated)
|
||||
}
|
||||
|
||||
// Removal: drop the matching nat rule line.
|
||||
// Removal: drop every matching nat rule line, not just the first, so a chain
|
||||
// holding duplicate equivalent lines comes clean in one call — mirroring the
|
||||
// filter path's removal loop.
|
||||
if natStart == -1 || commitIdx == -1 {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -2203,7 +2314,7 @@ func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) e
|
|||
updated = append(updated, raw)
|
||||
continue
|
||||
}
|
||||
if inNat && !removed && !f.IgnoreLine(t) {
|
||||
if inNat && !f.IgnoreLine(t) {
|
||||
if nr, perr := f.UnmarshalNATRule(t, f.fileFamily(path)); perr == nil && nr.EqualBase(r) {
|
||||
removed = true
|
||||
continue
|
||||
|
|
@ -2537,8 +2648,13 @@ func (f *IPTables) ipsetParseType(fields []string) (Family, SetType) {
|
|||
func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
||||
out, err := runCommand(ctx, "ipset", "save")
|
||||
if err != nil {
|
||||
// ipset not installed, or no sets: nothing to report.
|
||||
return nil, nil
|
||||
// An absent ipset binary means no sets to report; any other failure
|
||||
// (permission denial, a missing kernel module) must surface — reading it
|
||||
// as "no sets" would let a Backup silently capture none.
|
||||
if !commandExists("ipset") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
sets := map[string]*AddressSet{}
|
||||
var names []string
|
||||
|
|
@ -2552,7 +2668,9 @@ func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
|||
}
|
||||
for _, line := range out {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 3 && fields[0] == "add" {
|
||||
// An add line may carry entry options (`timeout 600`, `comment "x"`);
|
||||
// the entry itself is still the third field.
|
||||
if len(fields) >= 3 && fields[0] == "add" {
|
||||
if set, ok := sets[fields[1]]; ok {
|
||||
set.Entries = append(set.Entries, fields[2])
|
||||
}
|
||||
|
|
@ -2841,6 +2959,7 @@ func (f *IPTables) rewriteFilterRules(path string, ruleLines []string) error {
|
|||
// and writes (PREROUTING/POSTROUTING). rewriteNATRules replaces the rules in these
|
||||
// chains and preserves every other nat chain verbatim — including OUTPUT, whose
|
||||
// locally-generated DNAT the NATRule model cannot represent distinctly (see
|
||||
// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING.
|
||||
func (f *IPTables) managedNATChain(chain string) bool {
|
||||
switch chain {
|
||||
case "PREROUTING", "POSTROUTING":
|
||||
|
|
@ -2849,19 +2968,51 @@ func (f *IPTables) managedNATChain(chain string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// preservedNATLines returns the indices of managed-chain (-A PREROUTING /
|
||||
// POSTROUTING) lines a rewrite must keep verbatim because UnmarshalNATRule
|
||||
// cannot model them: an unsupported jump (-j DOCKER, -j RETURN), an unmodeled
|
||||
// or negated match, a source-port match. Such a line is invisible to
|
||||
// GetNATRules, so it never appears in the desired set and would otherwise be
|
||||
// deleted — severing, for example, Docker's port publishing on a Backup/Restore
|
||||
// round trip. It is the nat counterpart of preservedFilterLines.
|
||||
func (f *IPTables) preservedNATLines(lines []string) map[int]bool {
|
||||
preserved := map[int]bool{}
|
||||
inNat := false
|
||||
for i, line := range lines {
|
||||
t := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(t, "*") {
|
||||
inNat = t == "*nat"
|
||||
continue
|
||||
}
|
||||
body := f.ruleLineBody(t)
|
||||
if !inNat || !strings.HasPrefix(body, "-A ") {
|
||||
continue
|
||||
}
|
||||
if !f.managedNATChain(f.chainOf(body)) {
|
||||
continue
|
||||
}
|
||||
if _, err := f.UnmarshalNATRule(t, FamilyAny); err != nil {
|
||||
preserved[i] = true
|
||||
}
|
||||
}
|
||||
return preserved
|
||||
}
|
||||
|
||||
// rewriteNATRules atomically rewrites path so that the *nat table's rule lines in
|
||||
// the managed chains are exactly natLines, leaving the chain-policy lines, any
|
||||
// user-defined nat chain, the *filter table and all other content untouched. A
|
||||
// file with no *nat table gains one. It is the nat counterpart of
|
||||
// user-defined nat chain, unmodeled managed-chain lines, the *filter table and
|
||||
// all other content untouched. A file with no *nat table gains one. It is the
|
||||
// nat counterpart of rewriteFilterRules.
|
||||
func (f *IPTables) rewriteNATRules(path string, natLines []string) error {
|
||||
lines, err := f.readAllLines(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preserved := f.preservedNATLines(lines)
|
||||
out := make([]string, 0, len(lines)+len(natLines))
|
||||
inNat := false
|
||||
inserted := false
|
||||
for _, line := range lines {
|
||||
for idx, line := range lines {
|
||||
t := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(t, "*"):
|
||||
|
|
@ -2876,9 +3027,10 @@ func (f *IPTables) rewriteNATRules(path string, natLines []string) error {
|
|||
inNat = false
|
||||
case inNat && strings.HasPrefix(f.ruleLineBody(t), "-A "):
|
||||
// Drop an existing rule in a managed chain — the desired set replaces it —
|
||||
// but preserve a rule in a user-defined nat chain verbatim, mirroring how
|
||||
// rewriteFilterRules preserves FORWARD/custom-chain rules.
|
||||
if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) {
|
||||
// but preserve a rule in a user-defined nat chain, and an unmodeled
|
||||
// managed-chain line, verbatim, mirroring how rewriteFilterRules
|
||||
// preserves FORWARD/custom-chain rules and unmodeled filter lines.
|
||||
if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) || preserved[idx] {
|
||||
out = append(out, line)
|
||||
}
|
||||
default:
|
||||
|
|
@ -3154,7 +3306,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Scan each line to find where we should insert our rule.
|
||||
// Scan each line to find the insertion point.
|
||||
scanner := bufio.NewScanner(fd)
|
||||
writtenRule := false
|
||||
filterFound := false
|
||||
|
|
@ -3165,7 +3317,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
|
|||
writtenRule = true
|
||||
}
|
||||
for scanner.Scan() {
|
||||
// Trim line and check if we skip the line.
|
||||
// Trim the line and check whether it is skipped.
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Look for the filter, and skip if not reached.
|
||||
|
|
@ -3202,7 +3354,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
|
|||
// A rule that was never written means the filter table was malformed.
|
||||
if !writtenRule {
|
||||
af.Abort()
|
||||
return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file")
|
||||
return nil, fmt.Errorf("failed to write the new rule to the iptables-save file")
|
||||
}
|
||||
|
||||
return af, nil
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
844
nft_linux.go
844
nft_linux.go
File diff suppressed because it is too large
Load diff
|
|
@ -549,6 +549,9 @@ func TestNFTLogLimitRoundTrip(t *testing.T) {
|
|||
// A non-default burst (nft's default is 5, which normalizes to the unset 0).
|
||||
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 10}},
|
||||
{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}},
|
||||
// Per-source counting keys on the source address in a named meter set
|
||||
// (TestNFTPerSourceConnLimit covers the listed-form parses).
|
||||
{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}},
|
||||
{Family: IPv4, Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept},
|
||||
{Family: IPv4, Proto: TCP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
|
||||
}
|
||||
|
|
@ -559,10 +562,6 @@ func TestNFTLogLimitRoundTrip(t *testing.T) {
|
|||
require.NoError(t, err, "expr %q", expr)
|
||||
require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got)
|
||||
}
|
||||
|
||||
// Per-source connection limiting is not expressible in this model.
|
||||
_, _, err := f.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNFTNATRoundTrip(t *testing.T) {
|
||||
|
|
@ -740,3 +739,155 @@ func TestNFTNATRejectsMergedProtocol(t *testing.T) {
|
|||
_, _, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCPUDP, Port: 80, ToAddress: "192.0.2.1"})
|
||||
require.ErrorIs(t, err, ErrUnsupportedNAT)
|
||||
}
|
||||
|
||||
// A LogPrefix or comment with consecutive interior spaces must round-trip:
|
||||
// LogPrefix is part of rule identity, so a collapsed read-back would never match
|
||||
// its own rule and Sync would add a duplicate row each pass.
|
||||
func TestNFTQuotedSpacingRoundTrip(t *testing.T) {
|
||||
fw := &NFT{table: "go_firewall"}
|
||||
orig := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop,
|
||||
Log: true, LogPrefix: "FW DROP: ", Comment: "web block # one"}
|
||||
chain, expr, err := fw.MarshalRule(orig)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, handle, err := fw.UnmarshalRule(expr+" # handle 9", chain)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "9", handle)
|
||||
require.Equal(t, "FW DROP: ", parsed.LogPrefix, "interior spacing must survive the round trip")
|
||||
require.Equal(t, "web block # one", parsed.Comment)
|
||||
require.True(t, parsed.Equal(orig, true), "want %+v got %+v", orig, parsed)
|
||||
}
|
||||
|
||||
// An unrecognized single l4proto token is a foreign rule's protocol the model
|
||||
// cannot hold; it must fail the parse (leaving the row opaque) rather than read
|
||||
// back as a match-every-protocol rule.
|
||||
func TestNFTUnknownL4ProtoRejected(t *testing.T) {
|
||||
fw := &NFT{table: "go_firewall"}
|
||||
_, _, err := fw.UnmarshalRule("meta l4proto igmp accept # handle 3", "input")
|
||||
require.Error(t, err)
|
||||
_, _, err = fw.UnmarshalRule("meta l4proto 253 accept # handle 4", "input")
|
||||
require.Error(t, err)
|
||||
// The one modeled set and the plain transports still parse.
|
||||
r, _, err := fw.UnmarshalRule("meta l4proto { tcp, udp } th dport 53 accept # handle 5", "input")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, TCPUDP, r.Proto)
|
||||
}
|
||||
|
||||
// sanitizeNFTName must never yield an identifier nftables rejects: identifiers
|
||||
// begin with a letter.
|
||||
func TestNFTSanitizeNameLeadingDigit(t *testing.T) {
|
||||
require.Equal(t, "fw_9lives", sanitizeNFTName("9lives"))
|
||||
require.Equal(t, "myapp", sanitizeNFTName("myapp"))
|
||||
require.Equal(t, NFTDefaultTable, sanitizeNFTName("!!!"))
|
||||
}
|
||||
|
||||
// lineHandle pulls the trailing handle marker off a rule line the model cannot
|
||||
// parse, so the row still gets an opaque slot; a quoted comment containing the
|
||||
// word "handle" cannot fool the end-anchored scan.
|
||||
func TestNFTLineHandle(t *testing.T) {
|
||||
fw := &NFT{table: "go_firewall"}
|
||||
require.Equal(t, "12", fw.lineHandle(`meta mark 0x1 accept # handle 12`))
|
||||
require.Equal(t, "7", fw.lineHandle(`tcp dport 22 accept comment "keep handle steady" # handle 7`))
|
||||
require.Equal(t, "", fw.lineHandle("type filter hook input priority filter; policy accept;"))
|
||||
}
|
||||
|
||||
// physicalIndex maps a 1-based modeled-row position to the physical index,
|
||||
// keeping opaque rows pinned before the slot.
|
||||
func TestNFTPhysicalIndex(t *testing.T) {
|
||||
a, b := &Rule{Port: 1}, &Rule{Port: 2}
|
||||
rows := []*Rule{nil, a, nil, b}
|
||||
require.Equal(t, 1, physicalIndex(rows, 1))
|
||||
require.Equal(t, 3, physicalIndex(rows, 2))
|
||||
require.Equal(t, 4, physicalIndex(rows, 3), "past the last modeled row appends")
|
||||
}
|
||||
|
||||
// A family-agnostic set-referencing rule cannot be rendered faithfully — a named
|
||||
// set is family-typed — so the entry points pin it to the referenced set's own
|
||||
// family (resolveSetRefFamily) before marshalling, and the marshaller guards
|
||||
// against an unresolved one reaching it rather than silently pinning to IPv4.
|
||||
func TestNFTSetRefRequiresConcreteFamily(t *testing.T) {
|
||||
fw := &NFT{table: "go_firewall"}
|
||||
_, _, err := fw.MarshalRule(&Rule{Source: "blocklist", Action: Drop})
|
||||
require.Error(t, err, "an unresolved family-agnostic set reference must not marshal")
|
||||
_, _, err = fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Action: Drop})
|
||||
require.NoError(t, err)
|
||||
// A NAT rule whose only family signal is the set reference is equally
|
||||
// unrenderable; one with a concrete address is implied by it and passes.
|
||||
_, _, err = fw.MarshalNATRule(&NATRule{Kind: Masquerade, Source: "natpool"})
|
||||
require.Error(t, err)
|
||||
_, _, err = fw.MarshalNATRule(&NATRule{Kind: SNAT, Source: "natpool", ToAddress: "192.0.2.1"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// A per-source connection limit marshals as a named meter keyed on the source
|
||||
// address and reads back from nft's normalized `add @set { ... }` listing (and
|
||||
// the written `meter`/`update` spellings). The meter key pins the family, so no
|
||||
// nfproto match is emitted — nft would fold it away and the text would never
|
||||
// round-trip.
|
||||
func TestNFTPerSourceConnLimit(t *testing.T) {
|
||||
fw := &NFT{table: "go_firewall"}
|
||||
|
||||
r := &Rule{Family: IPv4, Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}
|
||||
chain, expr, err := fw.MarshalRule(r)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "input", chain)
|
||||
require.Contains(t, expr, "meter cl")
|
||||
require.Contains(t, expr, "{ ip saddr ct count over 15 }")
|
||||
require.NotContains(t, expr, "nfproto", "the meter key already pins the family; an nfproto match would not round-trip")
|
||||
|
||||
// The written form parses back to the same rule.
|
||||
parsed, _, err := fw.UnmarshalRule(expr, chain)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed.Equal(r, true), "written form mismatch: %+v vs %+v", parsed, r)
|
||||
|
||||
// nft lists the meter as an `add @set` statement; the parse captures the
|
||||
// count, the per-source flag, the family from the key, and the set name for
|
||||
// removal-time cleanup.
|
||||
listed := "tcp dport 8081 add @cl4a5b { ip saddr ct count over 15 } counter packets 0 bytes 0 reject with icmp port-unreachable # handle 7"
|
||||
parsed, handle, err := fw.UnmarshalRule(listed, "input")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "7", handle)
|
||||
require.True(t, parsed.Equal(r, true), "listed form mismatch: %+v vs %+v", parsed, r)
|
||||
require.Equal(t, "cl4a5b", parsed.meterSet, "the set name must be captured for removal-time cleanup")
|
||||
|
||||
// The IPv6 key reads back as an IPv6 rule.
|
||||
listed6 := "tcp dport 8081 add @cl6f { ip6 saddr ct count over 15 } counter packets 0 bytes 0 reject # handle 9"
|
||||
parsed6, _, err := fw.UnmarshalRule(listed6, "input")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IPv6, parsed6.Family, "the meter key must pin the family")
|
||||
require.True(t, parsed6.ConnLimit != nil && parsed6.ConnLimit.PerSource && parsed6.ConnLimit.Count == 15)
|
||||
|
||||
// The `update` spelling (a timeout-refreshing meter) parses the same way.
|
||||
updated := "tcp dport 8081 update @cl4a5b { ip saddr ct count over 15 } counter packets 0 bytes 0 drop # handle 11"
|
||||
parsedU, _, err := fw.UnmarshalRule(updated, "input")
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsedU.ConnLimit != nil && parsedU.ConnLimit.PerSource)
|
||||
|
||||
// nft 1.0.x echoes the written meter spelling back, with a size qualifier.
|
||||
legacy := "tcp dport 8081 meter cl4a5b size 65535 { ip saddr ct count over 15 } counter packets 0 bytes 0 reject with icmp port-unreachable # handle 13"
|
||||
parsedL, _, err := fw.UnmarshalRule(legacy, "input")
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsedL.Equal(r, true), "nft 1.0.x meter listing mismatch: %+v vs %+v", parsedL, r)
|
||||
require.Equal(t, "cl4a5b", parsedL.meterSet)
|
||||
|
||||
// An unmodeled dynamic-set body fails the parse (the row stays opaque)
|
||||
// rather than reading back as a wrong rule.
|
||||
_, _, err = fw.UnmarshalRule("tcp dport 80 add @tracker { ip saddr limit rate 10/second } counter drop # handle 12", "input")
|
||||
require.Error(t, err, "a non-connlimit dynamic-set body is not modeled")
|
||||
|
||||
// A FamilyAny per-source rule has no single row; the entry points fan it out
|
||||
// before marshalling, so the marshaller itself refuses it.
|
||||
_, _, err = fw.MarshalRule(&Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
|
||||
require.Error(t, err)
|
||||
|
||||
// The meter name is deterministic for one rule and distinct across rules.
|
||||
r2 := &Rule{Family: IPv4, Proto: TCP, Port: 8082, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}
|
||||
require.Equal(t, fw.meterName("input", r), fw.meterName("input", r), "same rule, same set")
|
||||
require.NotEqual(t, fw.meterName("input", r), fw.meterName("input", r2), "distinct rules must not share counting state")
|
||||
|
||||
// A global (non-per-source) limit keeps the plain ct count form.
|
||||
_, exprGlobal, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, exprGlobal, "ct count over 20")
|
||||
require.NotContains(t, exprGlobal, "meter")
|
||||
}
|
||||
|
|
|
|||
155
pf.go
155
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
|
||||
// <table> references resolve to nothing.
|
||||
return nil, err
|
||||
}
|
||||
var result []*AddressSet
|
||||
for _, line := range out {
|
||||
|
|
@ -1905,7 +1959,10 @@ func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
|||
continue
|
||||
}
|
||||
set, err := f.getAddressSet(ctx, name)
|
||||
if err != nil || set == nil {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if set == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, set)
|
||||
|
|
@ -1933,14 +1990,11 @@ func (f *PF) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
|||
if err := f.ensureAnchor(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(set.Entries) == 0 {
|
||||
// pf does not lazily create tables, so a later filter rule referencing
|
||||
// <name> would fail to load. `-T replace` with no addresses creates the
|
||||
// table empty (or empties an existing one) so it exists for the caller.
|
||||
_, err := runCommand(ctx, "pfctl", "-t", set.Name, "-T", "replace")
|
||||
return err
|
||||
}
|
||||
args := []string{"-t", set.Name, "-T", "add"}
|
||||
// `-T replace` reconciles an existing table to exactly the given entries
|
||||
// (where `-T add` would only union), and with none creates the table empty —
|
||||
// pf does not lazily create tables, so a later filter rule referencing
|
||||
// <name> would otherwise fail to load.
|
||||
args := []string{"-t", set.Name, "-T", "replace"}
|
||||
args = append(args, set.Entries...)
|
||||
_, err := runCommand(ctx, "pfctl", args...)
|
||||
return err
|
||||
|
|
@ -2031,13 +2085,11 @@ func (f *PF) Restore(ctx context.Context, zoneName string, backup *Backup) error
|
|||
return err
|
||||
}
|
||||
|
||||
filterLines := make([]string, 0, len(backup.Rules))
|
||||
for _, r := range backup.Rules {
|
||||
line, err := f.MarshalRule(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filterLines = append(filterLines, line)
|
||||
// A portable backup may carry DirAny or TCPUDP rules captured from a backend
|
||||
// that stores them as one row; expand exactly as the add paths do.
|
||||
filterLines, err := f.marshalExpanded(backup.Rules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
natLines := make([]string, 0, len(backup.NATRules))
|
||||
|
|
@ -2120,20 +2172,9 @@ func (f *PF) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Ru
|
|||
return err
|
||||
}
|
||||
|
||||
var filterRaw []string
|
||||
for _, top := range rules {
|
||||
// A DirAny rule fans out into an inbound rule plus its swapped outbound rule,
|
||||
// and a TCPUDP rule into a tcp rule and a udp rule; pfctl stores each
|
||||
// direction/transport as its own row.
|
||||
for _, dir := range expandDirections(top) {
|
||||
for _, r := range expandProtocols(dir) {
|
||||
line, err := f.MarshalRule(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filterRaw = append(filterRaw, line)
|
||||
}
|
||||
}
|
||||
filterRaw, err := f.marshalExpanded(rules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.loadAnchor(ctx, natRaw, filterRaw)
|
||||
}
|
||||
|
|
|
|||
86
pf_test.go
86
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))
|
||||
}
|
||||
|
|
|
|||
42
services.go
42
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
|
||||
|
|
|
|||
209
ufw_linux.go
209
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
44
utils.go
44
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.
|
||||
|
|
|
|||
146
wf_windows.go
146
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue