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_SRC := $(shell find $(CLI_DIR) . -maxdepth 1 -name '*.go' -not -name '*_test.go') \
|
||||||
$(CLI_DIR)/go.mod $(CLI_DIR)/go.sum go.mod go.sum
|
$(CLI_DIR)/go.mod $(CLI_DIR)/go.sum go.mod go.sum
|
||||||
|
|
||||||
.PHONY: all lint test test-general test-integration For test-integration-linux test-integration-freebsd test-integration-windows cli install clean
|
.PHONY: all lint test test-general test-integration test-integration-linux test-integration-freebsd test-integration-windows cli install clean
|
||||||
|
|
||||||
# Bare `make` builds the CLI.
|
# Bare `make` builds the CLI.
|
||||||
.DEFAULT_GOAL := all
|
.DEFAULT_GOAL := all
|
||||||
|
|
|
||||||
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) |
|
| SCTP/GRE/ESP/AH | rich rule | yes | yes | yes | yes | yes | yes | by number (no SCTP port) |
|
||||||
| Comment | no | yes | yes | yes | yes | yes | label | description |
|
| Comment | no | yes | yes | yes | yes | yes | label | description |
|
||||||
| Port range | yes | yes | yes | yes | yes | yes | yes | yes |
|
| Port range | yes | yes | yes | yes | yes | yes | yes | yes |
|
||||||
| Port list | no | yes | yes | ports config | yes | yes | yes | yes |
|
| Port list | no | yes | adv rule / hook | via hook | yes | yes | yes | yes |
|
||||||
| Source port | yes | yes | adv rule | adv rule | yes | yes | yes | yes |
|
| Source port | yes | yes | adv rule | adv rule | yes | yes | yes | yes |
|
||||||
| Connection state | no | yes | yes | yes | yes | yes | no (stateful) | no |
|
| Connection state | no | yes | yes | yes | yes | yes | no (stateful) | no |
|
||||||
| Interface match | no (zone) | yes | yes | yes | yes | yes | yes | no |
|
| Interface match | no (zone) | yes | yes | yes | yes | yes | yes | no |
|
||||||
|
|
@ -321,6 +321,13 @@ below still documents how each backend expresses these features.
|
||||||
| Connection limit | no | yes | per-port | per-port | yes | yes | per-source | no |
|
| Connection limit | no | yes | per-port | per-port | yes | yes | per-source | no |
|
||||||
| NAT | fwd-port/masq | yes | dnat/redirect | dnat/snat/masq | yes | yes | rdr/nat (no redirect) | no |
|
| NAT | fwd-port/masq | yes | dnat/redirect | dnat/snat/masq | yes | yes | rdr/nat (no redirect) | no |
|
||||||
|
|
||||||
|
`Capabilities().DenyActionFromConfig` (true for CSF and APF) flags a backend whose
|
||||||
|
native deny store carries no per-entry action: the tool applies the action its own
|
||||||
|
config names (`csf.conf` `DROP`, `conf.apf` `ALL_STOP`), so a deny added with the
|
||||||
|
config's action is stored natively, one with a differing action is expressed
|
||||||
|
through the backend's pre-hook, and `RemoveRule` clears a native deny entry
|
||||||
|
whatever action the removal target names.
|
||||||
|
|
||||||
## Default policy
|
## Default policy
|
||||||
|
|
||||||
`GetDefaultPolicy`/`SetDefaultPolicy` read and set the default action applied to
|
`GetDefaultPolicy`/`SetDefaultPolicy` read and set the default action applied to
|
||||||
|
|
@ -355,7 +362,8 @@ construct:
|
||||||
| nftables | a set in the private `inet` table |
|
| nftables | a set in the private `inet` table |
|
||||||
| firewalld | a firewalld ipset (D-Bus) |
|
| firewalld | a firewalld ipset (D-Bus) |
|
||||||
| pf | a pf table |
|
| pf | a pf table |
|
||||||
| CSF/APF/WFP | unsupported (`ErrUnsupportedSet`) |
|
| CSF/APF | ipset commands in the managed pre-hook |
|
||||||
|
| WFP | unsupported (`ErrUnsupportedSet`) |
|
||||||
|
|
||||||
```go
|
```go
|
||||||
set := &firewall.AddressSet{Name: "blocklist", Family: firewall.IPv4, Type: firewall.SetHashNet}
|
set := &firewall.AddressSet{Name: "blocklist", Family: firewall.IPv4, Type: firewall.SetHashNet}
|
||||||
|
|
|
||||||
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
|
// concrete-IPv6 rule (see ipv6Unavailable) rather than write one apf will never
|
||||||
// enforce.
|
// enforce.
|
||||||
ipv6Enabled bool
|
ipv6Enabled bool
|
||||||
|
// stopActions caches conf.apf's STOP settings, loaded on first use:
|
||||||
|
// deny_hosts commonly holds thousands of entries and each parsed line
|
||||||
|
// consults a STOP action, so per-line conf reads are avoided. The library
|
||||||
|
// never writes these keys.
|
||||||
|
stopActions map[string]Action
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPF verifies apf is installed and active, then returns a manager bound to rulePrefix.
|
// NewAPF verifies apf is installed and active, then returns a manager bound to rulePrefix.
|
||||||
|
|
@ -87,24 +92,35 @@ func (f *APF) Capabilities() Capabilities {
|
||||||
return Capabilities{
|
return Capabilities{
|
||||||
Output: true,
|
Output: true,
|
||||||
Forward: true,
|
Forward: true,
|
||||||
// ICMPv6 mirrors ipv6Enabled: with conf.apf's USE_IPV6 off, apf never touches
|
// ICMPv6 mirrors ipv6Enabled: with conf.apf's USE_IPV6 off, apf never
|
||||||
// ip6tables, so neither its native config nor the raw-iptables hook yields a
|
// touches ip6tables — an IG_ICMPV6_TYPES entry yields no rule — so neither
|
||||||
// rule apf will keep in sync across a reload (see ipv6Unavailable). Confirmed
|
// its native config nor the raw-iptables hook yields a rule apf will keep
|
||||||
// against a real apf install: ip6tables carries no rule for a type in
|
// in sync across a reload (see ipv6Unavailable).
|
||||||
// IG_ICMPV6_TYPES when USE_IPV6=0.
|
ICMPv6: f.ipv6Enabled,
|
||||||
ICMPv6: f.ipv6Enabled,
|
// PortList is honored through the pre-hook: apf's advanced rule holds a
|
||||||
PortList: false,
|
// single port or one range, and a conf.apf CPORTS list stores each port
|
||||||
ConnState: true,
|
// as an independent token that reads back as its own rule, so every
|
||||||
InterfaceMatch: true,
|
// multi-port rule is injected as a single `-m multiport` iptables line
|
||||||
Logging: true,
|
// instead (see needsHook).
|
||||||
RateLimit: true,
|
PortList: true,
|
||||||
ConnLimit: true,
|
PortPair: true,
|
||||||
NAT: true,
|
ConnState: true,
|
||||||
RuleOrdering: false,
|
InterfaceMatch: true,
|
||||||
DefaultPolicy: false,
|
Logging: true,
|
||||||
RuleCounters: false,
|
RateLimit: true,
|
||||||
AddressSets: true,
|
ConnLimit: true,
|
||||||
Comments: 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)
|
// readStopAction reads the named STOP setting (ALL_STOP/TCP_STOP/UDP_STOP)
|
||||||
// from a conf.apf-format file, defaulting to the stock DROP when the key is
|
// from a conf.apf-format file, defaulting to the stock DROP when the key is
|
||||||
// absent or the file cannot be read. path is a parameter (rather than always
|
// absent or the file cannot be read.
|
||||||
// APFConf) so this can be exercised against a fixture file in tests.
|
|
||||||
func (f *APF) readStopAction(path, key string) Action {
|
func (f *APF) readStopAction(path, key string) Action {
|
||||||
action := Drop
|
action := Drop
|
||||||
fd, err := os.Open(path)
|
fd, err := os.Open(path)
|
||||||
|
|
@ -292,21 +307,35 @@ func (f *APF) stopKey(proto Protocol) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// denyActionFor reads conf.apf's setting for the given protocol (see
|
// confStopActions returns conf.apf's three STOP settings, read once per manager
|
||||||
|
// and cached (see the stopActions field).
|
||||||
|
func (f *APF) confStopActions() map[string]Action {
|
||||||
|
if f.stopActions == nil {
|
||||||
|
f.stopActions = map[string]Action{
|
||||||
|
"ALL_STOP": f.readStopAction(APFConf, "ALL_STOP"),
|
||||||
|
"TCP_STOP": f.readStopAction(APFConf, "TCP_STOP"),
|
||||||
|
"UDP_STOP": f.readStopAction(APFConf, "UDP_STOP"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.stopActions
|
||||||
|
}
|
||||||
|
|
||||||
|
// denyActionFor returns the action apf applies to a deny of the given protocol,
|
||||||
|
// from conf.apf's STOP settings (see stopKey).
|
||||||
func (f *APF) denyActionFor(proto Protocol) Action {
|
func (f *APF) denyActionFor(proto Protocol) Action {
|
||||||
|
stops := f.confStopActions()
|
||||||
// A TCPUDP deny is one protocol-less advanced line, which apf applies with
|
// A TCPUDP deny is one protocol-less advanced line, which apf applies with
|
||||||
// TCP_STOP on the tcp rule it derives and UDP_STOP on the udp one. It therefore
|
// TCP_STOP on the tcp rule it derives and UDP_STOP on the udp one. It therefore
|
||||||
// has a single native action only when the two settings agree; when they differ
|
// has a single native action only when the two settings agree; when they differ
|
||||||
// the rule cannot be one Rule with one Action, so report ActionInvalid and let
|
// the rule cannot be one Rule with one Action, so report ActionInvalid and let
|
||||||
// addRule route it to the hook, whose lines carry the action verbatim.
|
// addRule route it to the hook, whose lines carry the action verbatim.
|
||||||
if proto == TCPUDP {
|
if proto == TCPUDP {
|
||||||
tcp := f.readStopAction(APFConf, "TCP_STOP")
|
if stops["UDP_STOP"] != stops["TCP_STOP"] {
|
||||||
if f.readStopAction(APFConf, "UDP_STOP") != tcp {
|
|
||||||
return ActionInvalid
|
return ActionInvalid
|
||||||
}
|
}
|
||||||
return tcp
|
return stops["TCP_STOP"]
|
||||||
}
|
}
|
||||||
return f.readStopAction(APFConf, f.stopKey(proto))
|
return stops[f.stopKey(proto)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAction resolves the action to stamp on a rule parsed from — or
|
// resolveAction resolves the action to stamp on a rule parsed from — or
|
||||||
|
|
@ -828,17 +857,10 @@ func (f *APF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add or remove the rule's tokens from the comma list, preserving any others.
|
// Add or remove the rule's tokens from the comma list, preserving any others.
|
||||||
// Three collections cooperate here:
|
// want holds the canonical form of every token the rule contributes and
|
||||||
// - want: the canonical form of every token this rule contributes, so a
|
// present the canonical form of every kept token, so removal recognizes a
|
||||||
// remove can recognize an existing token as "ours" regardless of
|
// token however the file spells it and an add never appends a duplicate;
|
||||||
// how it was spelled in the file.
|
// kept carries the surviving tokens in their original spelling.
|
||||||
// - present: the canonical form of every token we decide to keep, so the
|
|
||||||
// add pass can tell whether one of the rule's tokens is already
|
|
||||||
// in the list and must not be appended a second time.
|
|
||||||
// - kept: the tokens, in original spelling, that survive into the
|
|
||||||
// rewritten list. This is the actual output.
|
|
||||||
// want and present are keyed by canonical form (comparison identity); kept
|
|
||||||
// holds the verbatim tokens (what we write back).
|
|
||||||
want := make(map[string]bool, len(wantTokens))
|
want := make(map[string]bool, len(wantTokens))
|
||||||
for _, w := range wantTokens {
|
for _, w := range wantTokens {
|
||||||
want[canon(w)] = true
|
want[canon(w)] = true
|
||||||
|
|
@ -846,10 +868,9 @@ func (f *APF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
|
||||||
present := make(map[string]bool)
|
present := make(map[string]bool)
|
||||||
var kept []string
|
var kept []string
|
||||||
|
|
||||||
// Pass 1: walk the existing config list. Each token is either dropped (only
|
// Pass 1: walk the existing config list, dropping a token only on a remove
|
||||||
// on a remove, and only when it is one of the rule's own tokens) or kept.
|
// and only when it is one of the rule's own; surviving tokens keep their
|
||||||
// We keep the original spelling but track it by canonical form so pass 2 can
|
// original spelling and are tracked by canonical form for the add pass.
|
||||||
// dedupe against it.
|
|
||||||
for _, tok := range strings.Split(val, ",") {
|
for _, tok := range strings.Split(val, ",") {
|
||||||
tok = strings.TrimSpace(tok)
|
tok = strings.TrimSpace(tok)
|
||||||
if tok == "" {
|
if tok == "" {
|
||||||
|
|
@ -886,8 +907,39 @@ func (f *APF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditConf adds or removes a rule in conf.apf, rewriting the file in place.
|
// EditConf adds or removes a rule in conf.apf, rewriting the file in place.
|
||||||
|
// confKeyApplies reports whether a conf.apf key is one the rule's EditRulePort
|
||||||
|
// edit lands in, mirroring EditRulePort's own routing guards. EditConf uses it
|
||||||
|
// to detect a config missing the rule's key line (an operator-stripped or older
|
||||||
|
// conf.apf), where an add would otherwise report success with nothing written.
|
||||||
|
func (f *APF) confKeyApplies(key string, r *Rule) bool {
|
||||||
|
switch key {
|
||||||
|
case "IG_TCP_CPORTS":
|
||||||
|
return r.ConnLimit == nil && !r.IsOutput() && coversProtocol(r.Proto, TCP)
|
||||||
|
case "IG_UDP_CPORTS":
|
||||||
|
return r.ConnLimit == nil && !r.IsOutput() && coversProtocol(r.Proto, UDP)
|
||||||
|
case "EG_TCP_CPORTS":
|
||||||
|
return r.ConnLimit == nil && r.IsOutput() && coversProtocol(r.Proto, TCP)
|
||||||
|
case "EG_UDP_CPORTS":
|
||||||
|
return r.ConnLimit == nil && r.IsOutput() && coversProtocol(r.Proto, UDP)
|
||||||
|
case "IG_ICMP_TYPES":
|
||||||
|
return r.ConnLimit == nil && !r.IsOutput() && r.Proto == ICMP
|
||||||
|
case "EG_ICMP_TYPES":
|
||||||
|
return r.ConnLimit == nil && r.IsOutput() && r.Proto == ICMP
|
||||||
|
case "IG_ICMPV6_TYPES":
|
||||||
|
return r.ConnLimit == nil && !r.IsOutput() && r.Proto == ICMPv6
|
||||||
|
case "EG_ICMPV6_TYPES":
|
||||||
|
return r.ConnLimit == nil && r.IsOutput() && r.Proto == ICMPv6
|
||||||
|
case "IG_TCP_CLIMIT":
|
||||||
|
return f.isConnLimitRule(r) && r.Proto == TCP
|
||||||
|
case "IG_UDP_CLIMIT":
|
||||||
|
return f.isConnLimitRule(r) && r.Proto == UDP
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
|
func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
|
||||||
// For port only rules, open the standard config file.
|
// Open the standard config file; EditRulePort rewrites the port, icmp-type
|
||||||
|
// and CLIMIT list lines the rule applies to.
|
||||||
fd, err := os.Open(APFConf)
|
fd, err := os.Open(APFConf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -902,6 +954,7 @@ func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
|
||||||
defer af.Abort()
|
defer af.Abort()
|
||||||
|
|
||||||
// Parse config one line at a time, adding the port rule.
|
// Parse config one line at a time, adding the port rule.
|
||||||
|
keyMatched := false
|
||||||
scanner := bufio.NewScanner(fd)
|
scanner := bufio.NewScanner(fd)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
// Get the line.
|
// Get the line.
|
||||||
|
|
@ -933,6 +986,9 @@ func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
|
||||||
val = trimQuotes(strings.TrimSpace(val))
|
val = trimQuotes(strings.TrimSpace(val))
|
||||||
|
|
||||||
// Parse rules.
|
// Parse rules.
|
||||||
|
if f.confKeyApplies(key, r) {
|
||||||
|
keyMatched = true
|
||||||
|
}
|
||||||
orig = f.EditRulePort(orig, key, val, r, remove)
|
orig = f.EditRulePort(orig, key, val, r, remove)
|
||||||
_, _ = fmt.Fprintln(af, orig)
|
_, _ = fmt.Fprintln(af, orig)
|
||||||
}
|
}
|
||||||
|
|
@ -944,6 +1000,13 @@ func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
|
||||||
return serr
|
return serr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An add against a config missing the rule's key line would write nothing
|
||||||
|
// while reporting success — the rule's port or type would simply never
|
||||||
|
// open. Removal of a rule whose key line is absent is a plain no-op.
|
||||||
|
if !remove && !keyMatched {
|
||||||
|
return fmt.Errorf("conf.apf carries no config list for this rule")
|
||||||
|
}
|
||||||
|
|
||||||
// Move new file into place, preserving mode and ownership.
|
// Move new file into place, preserving mode and ownership.
|
||||||
return af.Commit()
|
return af.Commit()
|
||||||
}
|
}
|
||||||
|
|
@ -1037,7 +1100,7 @@ func (f *APF) listRows(action Action, match *Rule) []listRow {
|
||||||
// A bare all-protocol host allow/deny: a single address matching every
|
// A bare all-protocol host allow/deny: a single address matching every
|
||||||
// protocol. apf's trust files hold no other portless address shape — a
|
// protocol. apf's trust files hold no other portless address shape — a
|
||||||
// concrete-protocol host or a source+destination pair — so AddRule diverts
|
// concrete-protocol host or a source+destination pair — so AddRule diverts
|
||||||
// those to the raw-iptables hook (hostNeedsHook) and never reaches here with
|
// those to the raw-iptables hook (shapeNeedsHook) and never reaches here with
|
||||||
// one. A direct caller of this exported writer that supplies such a shape gets
|
// one. A direct caller of this exported writer that supplies such a shape gets
|
||||||
// a best-effort single-address write, not a guard.
|
// a best-effort single-address write, not a guard.
|
||||||
addr := match.Source
|
addr := match.Source
|
||||||
|
|
@ -1279,11 +1342,11 @@ func (f *APF) dualStackPortNeedsHook(r *Rule) bool {
|
||||||
// raw iptables rule because apf's native config cannot express it. It is the single
|
// raw iptables rule because apf's native config cannot express it. It is the single
|
||||||
// gate between the hook path and apf's config files: everything it rejects (returns
|
// gate between the hook path and apf's config files: everything it rejects (returns
|
||||||
// true) is written to the hook, everything it accepts (returns false) maps onto
|
// true) is written to the hook, everything it accepts (returns false) maps onto
|
||||||
// conf.apf or the allow_hosts/deny_hosts trust files. The shared shapes
|
// conf.apf or the allow_hosts/deny_hosts trust files. The shared predicates
|
||||||
// (ruleNeedsHook, bareHostOneWay, hostNeedsHook, advRuleNeedsHook) and the two apf
|
// (ruleNeedsHook, shapeNeedsHook, bareHostOneWay) and the two apf shapes RemoveRule
|
||||||
// shapes RemoveRule reuses to route a split (dualStackPortNeedsHook, nativeICMPv6)
|
// reuses to route a split (dualStackPortNeedsHook, nativeICMPv6) stay standalone;
|
||||||
// keep their own predicates; the apf-only, single-use port/source-port/connlimit/icmp
|
// the apf-only, single-use port/connlimit/icmp tests are inlined here as their sole
|
||||||
// tests are inlined here as their sole caller.
|
// caller.
|
||||||
func (f *APF) needsHook(r *Rule) bool {
|
func (f *APF) needsHook(r *Rule) bool {
|
||||||
// Features apf's native config cannot model — connection state, per-rule
|
// Features apf's native config cannot model — connection state, per-rule
|
||||||
// interface, logging, rate limiting, forward-chain routing, ICMPv6, or a
|
// interface, logging, rate limiting, forward-chain routing, ICMPv6, or a
|
||||||
|
|
@ -1293,25 +1356,20 @@ func (f *APF) needsHook(r *Rule) bool {
|
||||||
if ruleNeedsHook(r) && !f.nativeICMPv6(r) {
|
if ruleNeedsHook(r) && !f.nativeICMPv6(r) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// A one-way bare host has no trust-file form (a plain line is bidirectional, an
|
// A shape no native apf form holds — a one-way bare host, a source+destination
|
||||||
// advanced rule needs a port), and a concrete-protocol host or a source+
|
// pair, a concrete-protocol portless host, an advanced-line address/port-flow
|
||||||
// destination pair likewise has none (see hostNeedsHook); all go to the hook.
|
// overflow, or a bare protocol match (see shapeNeedsHook) — goes to the hook. A
|
||||||
if bareHostOneWay(r) || hostNeedsHook(r) {
|
// native address-less ICMP/ICMPv6 accept is excluded there and routed below.
|
||||||
|
if shapeNeedsHook(r) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// A ported source+destination pair, a source port matched with a destination port,
|
// A multi-port tcp/udp list apf's config cannot carry as one rule: its advanced
|
||||||
// and an address-less source-port match all overflow the advanced line's single
|
// rule holds a single port or one underscore range, and a conf.apf CPORTS comma
|
||||||
// address and single port-flow field (see advRuleNeedsHook); iptables matches each
|
// list stores each port as an independent token that reads back as its own rule,
|
||||||
// directly, so they go to the hook.
|
// so an address-less multi-port accept would never round-trip whole. Every list
|
||||||
if advRuleNeedsHook(r) {
|
// goes to the hook's iptables multiport match instead, which keeps it on one line.
|
||||||
return true
|
|
||||||
}
|
|
||||||
// A multi-port tcp/udp list apf's config cannot carry: its advanced rule holds a
|
|
||||||
// single port or one underscore range, and the only native multi-port shape is an
|
|
||||||
// address-less accept (isConfRule, carried by the IG_*_CPORTS comma lists);
|
|
||||||
// every other list goes to the hook's iptables multiport match.
|
|
||||||
if onProtocolAxis(r.Proto) &&
|
if onProtocolAxis(r.Proto) &&
|
||||||
(len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1) && !f.isConfRule(r) {
|
(len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// A connection limit conf.apf's IG_*_CLIMIT cannot express — anything but a
|
// A connection limit conf.apf's IG_*_CLIMIT cannot express — anything but a
|
||||||
|
|
@ -1327,14 +1385,6 @@ func (f *APF) needsHook(r *Rule) bool {
|
||||||
if r.Proto == ICMP && !f.isConfRule(r) {
|
if r.Proto == ICMP && !f.isConfRule(r) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// A bare protocol match with no address and no port has no native apf construct —
|
|
||||||
// the trust files key on an address and conf.apf's lists on a port or icmp type —
|
|
||||||
// but iptables expresses it directly, so it goes to the hook (see
|
|
||||||
// bareProtoNeedsHook). A native address-less ICMP/ICMPv6 accept is excluded there;
|
|
||||||
// connection limits and every other addressed/ported shape are routed above.
|
|
||||||
if bareProtoNeedsHook(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// A single-family bare tcp/udp port accept: apf's CPORTS lists are dual-stack, so
|
// A single-family bare tcp/udp port accept: apf's CPORTS lists are dual-stack, so
|
||||||
// only a FamilyAny port is native; a single-family one is written per-family
|
// only a FamilyAny port is native; a single-family one is written per-family
|
||||||
// through the hook (see dualStackPortNeedsHook, which RemoveRule also uses).
|
// through the hook (see dualStackPortNeedsHook, which RemoveRule also uses).
|
||||||
|
|
@ -1466,6 +1516,13 @@ func (f *APF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if s := splitDualRowDirection(e, r); s != nil {
|
if s := splitDualRowDirection(e, r); s != nil {
|
||||||
|
// An IPv6 plain line left over from when USE_IPV6 was on is not
|
||||||
|
// enforced with it off, and a hook-injected ip6tables line would
|
||||||
|
// outlive its own removal (see ipv6Unavailable); there is no coverage
|
||||||
|
// to preserve, so no v6 survivor is written.
|
||||||
|
if s.impliedFamily() == IPv6 && !f.ipv6Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
changed, err := f.hook().edit(s, false)
|
changed, err := f.hook().edit(s, false)
|
||||||
f.ConfigChanged = f.ConfigChanged || changed
|
f.ConfigChanged = f.ConfigChanged || changed
|
||||||
return err
|
return err
|
||||||
|
|
@ -1540,6 +1597,13 @@ func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error {
|
||||||
}
|
}
|
||||||
surviving := *r
|
surviving := *r
|
||||||
surviving.Family = oppositeFamily(r.impliedFamily())
|
surviving.Family = oppositeFamily(r.impliedFamily())
|
||||||
|
// With USE_IPV6 off apf never enforced the entry's IPv6 half, and a
|
||||||
|
// hook-injected ip6tables line is re-appended on every reload and
|
||||||
|
// outlives its own removal (see ipv6Unavailable), so there is no
|
||||||
|
// coverage to preserve and no v6 survivor to write.
|
||||||
|
if surviving.Family == IPv6 && !f.ipv6Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
changed, err := f.hook().edit(&surviving, false)
|
changed, err := f.hook().edit(&surviving, false)
|
||||||
f.ConfigChanged = f.ConfigChanged || changed
|
f.ConfigChanged = f.ConfigChanged || changed
|
||||||
return err
|
return err
|
||||||
|
|
@ -1556,7 +1620,8 @@ func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error {
|
||||||
// both. A read cannot tell which, so remove the rule from both backings — EditConf
|
// both. A read cannot tell which, so remove the rule from both backings — EditConf
|
||||||
// drops it from the CPORTS list and the hook edit drops both per-family rows, and
|
// drops it from the CPORTS list and the hook edit drops both per-family rows, and
|
||||||
// each no-ops when the rule is absent — clearing every cell the target covers,
|
// each no-ops when the rule is absent — clearing every cell the target covers,
|
||||||
// wherever it lives. Unlike the single-family
|
// wherever it lives. Unlike the single-family path there is no surviving family
|
||||||
|
// to re-express, so no split is needed.
|
||||||
func (f *APF) removeFamilyAnyPort(ctx context.Context, r *Rule) error {
|
func (f *APF) removeFamilyAnyPort(ctx context.Context, r *Rule) error {
|
||||||
if err := f.EditConf(ctx, r, true); err != nil {
|
if err := f.EditConf(ctx, r, true); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -1579,6 +1644,14 @@ func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the shape before the hook sweep below: an iptables-inexpressible
|
||||||
|
// rule (a port on ProtocolAny) exists nowhere apf can hold it, and letting it
|
||||||
|
// fail inside the hook marshal would return the bare error without the
|
||||||
|
// sentinel AddRule attaches to the same shape.
|
||||||
|
if err := iptablesRuleValid(r); err != nil {
|
||||||
|
return fmt.Errorf("%v: %w", err, ErrUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
// Clear any hook copy of the rule first, no matter how apf stores it. A rule apf
|
// Clear any hook copy of the rule first, no matter how apf stores it. A rule apf
|
||||||
// carries only in the hook (stateful/interface/logged/rate-limited/icmpv6/
|
// carries only in the hook (stateful/interface/logged/rate-limited/icmpv6/
|
||||||
// hook-only-proto) lives nowhere else, so this is its entire removal; a natively-
|
// hook-only-proto) lives nowhere else, so this is its entire removal; a natively-
|
||||||
|
|
@ -1627,9 +1700,6 @@ func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
if f.needsHook(r) {
|
if f.needsHook(r) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := iptablesRuleValid(r); err != nil {
|
|
||||||
return fmt.Errorf("%v: %w", err, ErrUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A native connection-limit rule maps onto conf.apf's IG_*_CLIMIT lists.
|
// A native connection-limit rule maps onto conf.apf's IG_*_CLIMIT lists.
|
||||||
if r.ConnLimit != nil {
|
if r.ConnLimit != nil {
|
||||||
|
|
@ -1719,20 +1789,6 @@ func (f *APF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, err
|
||||||
return rules, nil
|
return rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// natFamilies lists the address families a NAT rule is written for: a rule
|
|
||||||
// pinned to a family touches only that command; a family-agnostic rule (e.g. a
|
|
||||||
// portless masquerade) is written for both v4 and v6.
|
|
||||||
func (f *APF) natFamilies(r *NATRule) []Family {
|
|
||||||
switch r.impliedFamily() {
|
|
||||||
case IPv4:
|
|
||||||
return []Family{IPv4}
|
|
||||||
case IPv6:
|
|
||||||
return []Family{IPv6}
|
|
||||||
default:
|
|
||||||
return []Family{IPv4, IPv6}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// natFile returns the routing file a NAT rule belongs in: source NAT is
|
// natFile returns the routing file a NAT rule belongs in: source NAT is
|
||||||
// applied in POSTROUTING (postroute.rules), destination NAT in PREROUTING
|
// applied in POSTROUTING (postroute.rules), destination NAT in PREROUTING
|
||||||
// (preroute.rules).
|
// (preroute.rules).
|
||||||
|
|
@ -1743,14 +1799,6 @@ func (f *APF) natFile(r *NATRule) string {
|
||||||
return APFPreroute
|
return APFPreroute
|
||||||
}
|
}
|
||||||
|
|
||||||
// natCommand returns the iptables command name for a family.
|
|
||||||
func (f *APF) natCommand(fam Family) string {
|
|
||||||
if fam == IPv6 {
|
|
||||||
return "ip6tables"
|
|
||||||
}
|
|
||||||
return "iptables"
|
|
||||||
}
|
|
||||||
|
|
||||||
// natLine encodes a NAT rule as a raw iptables/ip6tables nat-table command
|
// natLine encodes a NAT rule as a raw iptables/ip6tables nat-table command
|
||||||
// line for the given family, the form apf's shell-sourced routing files expect.
|
// line for the given family, the form apf's shell-sourced routing files expect.
|
||||||
func (f *APF) natLine(r *NATRule, fam Family) (string, error) {
|
func (f *APF) natLine(r *NATRule, fam Family) (string, error) {
|
||||||
|
|
@ -1761,7 +1809,7 @@ func (f *APF) natLine(r *NATRule, fam Family) (string, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return f.natCommand(fam) + " -t nat " + spec, nil
|
return hookCommand(fam) + " -t nat " + spec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// editNATFile adds or removes a NAT rule's command line(s) in a routing file.
|
// editNATFile adds or removes a NAT rule's command line(s) in a routing file.
|
||||||
|
|
@ -1770,9 +1818,15 @@ func (f *APF) natLine(r *NATRule, fam Family) (string, error) {
|
||||||
func (f *APF) editNATFile(r *NATRule, remove bool) error {
|
func (f *APF) editNATFile(r *NATRule, remove bool) error {
|
||||||
path := f.natFile(r)
|
path := f.natFile(r)
|
||||||
|
|
||||||
// The line(s) this rule contributes, one per family it targets.
|
// The line(s) this rule contributes, one per family it targets. An add
|
||||||
|
// writes only the families apf enforces; a removal sweeps the full set
|
||||||
|
// (natRuleFamilies/natWriteFamilies, shared with the csf hook's NAT lines).
|
||||||
|
fams := natRuleFamilies(r)
|
||||||
|
if !remove {
|
||||||
|
fams = natWriteFamilies(f.ipv6Enabled, r)
|
||||||
|
}
|
||||||
want := make(map[string]bool)
|
want := make(map[string]bool)
|
||||||
for _, fam := range f.natFamilies(r) {
|
for _, fam := range fams {
|
||||||
line, err := f.natLine(r, fam)
|
line, err := f.natLine(r, fam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -1875,6 +1929,13 @@ func (f *APF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error
|
||||||
if err := r.validate(); err != nil {
|
if err := r.validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// A concrete-IPv6 translation cannot be kept in sync with USE_IPV6 off: apf
|
||||||
|
// sources the routing files on every (re)load but never flushes the v6 nat
|
||||||
|
// table, so the injected line re-appends each reload and outlives its own
|
||||||
|
// removal — the NAT analog of ipv6Unavailable.
|
||||||
|
if !f.ipv6Enabled && r.impliedFamily() == IPv6 {
|
||||||
|
return fmt.Errorf("apf cannot manage an IPv6 nat rule with USE_IPV6 disabled: %w", ErrUnsupportedNAT)
|
||||||
|
}
|
||||||
return f.editNATFile(r, false)
|
return f.editNATFile(r, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1933,6 +1994,7 @@ func (f *APF) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a
|
// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a
|
||||||
|
// hook rule still references the set; removing an absent set is a no-op.
|
||||||
func (f *APF) RemoveAddressSet(ctx context.Context, name string) error {
|
func (f *APF) RemoveAddressSet(ctx context.Context, name string) error {
|
||||||
changed, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
|
changed, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
|
||||||
f.ConfigChanged = f.ConfigChanged || changed
|
f.ConfigChanged = f.ConfigChanged || changed
|
||||||
|
|
@ -2023,12 +2085,15 @@ func (f *APF) Restore(ctx context.Context, zoneName string, backup *Backup) erro
|
||||||
// Reload restarts apf to apply config changes, but only when a mutation changed its files.
|
// Reload restarts apf to apply config changes, but only when a mutation changed its files.
|
||||||
func (f *APF) Reload(ctx context.Context) error {
|
func (f *APF) Reload(ctx context.Context) error {
|
||||||
// apf --restart rewrites and reloads the whole ruleset, which is disruptive, so
|
// apf --restart rewrites and reloads the whole ruleset, which is disruptive, so
|
||||||
// only restart when a mutation actually changed apf's config files.
|
// only restart when a mutation actually changed apf's config files. A
|
||||||
|
// successful restart consumes the flag; otherwise a second Reload with no
|
||||||
|
// intervening mutation would restart apf again for nothing.
|
||||||
if f.ConfigChanged {
|
if f.ConfigChanged {
|
||||||
_, err := runCommand(ctx, "/etc/apf/apf", "--restart")
|
_, err := runCommand(ctx, "/etc/apf/apf", "--restart")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
f.ConfigChanged = false
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ func TestAPFNATRoundTrip(t *testing.T) {
|
||||||
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
|
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
|
||||||
}
|
}
|
||||||
for _, orig := range cases {
|
for _, orig := range cases {
|
||||||
for _, fam := range fw.natFamilies(orig) {
|
for _, fam := range natRuleFamilies(orig) {
|
||||||
line, err := fw.natLine(orig, fam)
|
line, err := fw.natLine(orig, fam)
|
||||||
require.NoError(t, err, "marshal %+v", *orig)
|
require.NoError(t, err, "marshal %+v", *orig)
|
||||||
got, ok := fw.parseNATLine(line)
|
got, ok := fw.parseNATLine(line)
|
||||||
|
|
@ -593,7 +593,7 @@ func TestAPFDropRuleRemovable(t *testing.T) {
|
||||||
// A ported apf advanced rule that matches both a source and a destination address
|
// A ported apf advanced rule that matches both a source and a destination address
|
||||||
// cannot be expressed (apf's advanced rule holds a single address field), so AddRule
|
// cannot be expressed (apf's advanced rule holds a single address field), so AddRule
|
||||||
// routes it to the raw-iptables hook rather than silently dropping the destination.
|
// routes it to the raw-iptables hook rather than silently dropping the destination.
|
||||||
// The portless bare form takes the same path one predicate earlier (hostNeedsHook).
|
// The portless bare form takes the same path (shapeNeedsHook routes both).
|
||||||
func TestAPFDualAddressRouted(t *testing.T) {
|
func TestAPFDualAddressRouted(t *testing.T) {
|
||||||
fw := new(APF)
|
fw := new(APF)
|
||||||
require.True(t, fw.needsHook(&Rule{Proto: TCP, Port: 22, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}),
|
require.True(t, fw.needsHook(&Rule{Proto: TCP, Port: 22, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}),
|
||||||
|
|
@ -741,7 +741,7 @@ func TestAPFPortOnlyDenyHealsAndDoesNotDuplicate(t *testing.T) {
|
||||||
// A bare all-protocol host rule (address, no port) is the one portless address
|
// A bare all-protocol host rule (address, no port) is the one portless address
|
||||||
// shape apf's trust files express, written as the plain address line. The
|
// shape apf's trust files express, written as the plain address line. The
|
||||||
// inexpressible shapes — a concrete-protocol host or a source+destination pair —
|
// inexpressible shapes — a concrete-protocol host or a source+destination pair —
|
||||||
// are diverted to the hook by AddRule (hostNeedsHook) and never reach this
|
// are diverted to the hook by AddRule (shapeNeedsHook) and never reach this
|
||||||
// writer, so only the legitimate write is exercised here.
|
// writer, so only the legitimate write is exercised here.
|
||||||
func TestAPFBareHostWritten(t *testing.T) {
|
func TestAPFBareHostWritten(t *testing.T) {
|
||||||
fw := new(APF)
|
fw := new(APF)
|
||||||
|
|
@ -757,8 +757,9 @@ func TestAPFBareHostWritten(t *testing.T) {
|
||||||
|
|
||||||
// needsHook selects the multi-port lists apf's config cannot express, so
|
// needsHook selects the multi-port lists apf's config cannot express, so
|
||||||
// AddRule diverts them to the hook. A single port or one range is a valid apf
|
// AddRule diverts them to the hook. A single port or one range is a valid apf
|
||||||
// port token (stays native); an address-less tcp/udp accept list lives in
|
// port token (stays native); an address-less tcp/udp accept list is hooked too,
|
||||||
// conf.apf's CPORTS lists; a non-tcp/udp match is left to iptablesRuleValid.
|
// since a CPORTS entry reads back one rule per token and would never round-trip
|
||||||
|
// as the added list; a non-tcp/udp match is left to iptablesRuleValid.
|
||||||
func TestAPFPortNeedsHook(t *testing.T) {
|
func TestAPFPortNeedsHook(t *testing.T) {
|
||||||
fw := new(APF)
|
fw := new(APF)
|
||||||
list := []PortRange{{Start: 1000, End: 1000}, {Start: 2000, End: 2000}}
|
list := []PortRange{{Start: 1000, End: 1000}, {Start: 2000, End: 2000}}
|
||||||
|
|
@ -770,7 +771,7 @@ func TestAPFPortNeedsHook(t *testing.T) {
|
||||||
{"multi-port deny", &Rule{Proto: TCP, Ports: list, Action: Reject}, true},
|
{"multi-port deny", &Rule{Proto: TCP, Ports: list, Action: Reject}, true},
|
||||||
{"multi-port host accept", &Rule{Proto: TCP, Ports: list, Source: "1.2.3.4", Action: Accept}, true},
|
{"multi-port host accept", &Rule{Proto: TCP, Ports: list, Source: "1.2.3.4", Action: Accept}, true},
|
||||||
{"multi-source-port host", &Rule{Proto: UDP, SourcePorts: list, Source: "1.2.3.4", Action: Accept}, true},
|
{"multi-source-port host", &Rule{Proto: UDP, SourcePorts: list, Source: "1.2.3.4", Action: Accept}, true},
|
||||||
{"address-less accept list", &Rule{Proto: TCP, Ports: list, Action: Accept}, false},
|
{"address-less accept list", &Rule{Proto: TCP, Ports: list, Action: Accept}, true},
|
||||||
{"single port deny", &Rule{Proto: TCP, Port: 1000, Action: Reject}, false},
|
{"single port deny", &Rule{Proto: TCP, Port: 1000, Action: Reject}, false},
|
||||||
{"single range host", &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Source: "1.2.3.4", Action: Accept}, false},
|
{"single range host", &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Source: "1.2.3.4", Action: Accept}, false},
|
||||||
{"multi-port any-proto", &Rule{Ports: list, Action: Reject}, false},
|
{"multi-port any-proto", &Rule{Ports: list, Action: Reject}, false},
|
||||||
|
|
@ -780,6 +781,19 @@ func TestAPFPortNeedsHook(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAPFMultiPortRemovalSweepsCPortsTokens covers removal of a multi-port
|
||||||
|
// accept against a conf.apf CPORTS list: the rule lives in the hook now, but an
|
||||||
|
// earlier per-port add (or a manual edit) may hold the same ports as list
|
||||||
|
// tokens, so removeFamilyAnyPort's EditConf sweep must strip exactly the
|
||||||
|
// target's tokens and no others.
|
||||||
|
func TestAPFMultiPortRemovalSweepsCPortsTokens(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
target := &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
|
||||||
|
require.Equal(t, `IG_TCP_CPORTS="22"`,
|
||||||
|
fw.EditRulePort(`IG_TCP_CPORTS="22,80,443"`, "IG_TCP_CPORTS", "22,80,443", target, true),
|
||||||
|
"a multi-port removal must strip each of its own port tokens and keep the rest")
|
||||||
|
}
|
||||||
|
|
||||||
// APF EditIPList must write the missing IPv6 line when adding the IPv6 twin of an
|
// APF EditIPList must write the missing IPv6 line when adding the IPv6 twin of an
|
||||||
// existing IPv4 port-only deny; the family-agnostic EqualBase check previously
|
// existing IPv4 port-only deny; the family-agnostic EqualBase check previously
|
||||||
// treated the IPv4 line as covering IPv6 and wrote nothing, leaving IPv6 open.
|
// treated the IPv4 line as covering IPv6 and wrote nothing, leaving IPv6 open.
|
||||||
|
|
@ -972,17 +986,17 @@ func TestAPFTCPUDPRouting(t *testing.T) {
|
||||||
"a dual-stack tcpudp port accept lives in the CPORTS lists")
|
"a dual-stack tcpudp port accept lives in the CPORTS lists")
|
||||||
require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCPUDP, Port: 80, Action: Accept}),
|
require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCPUDP, Port: 80, Action: Accept}),
|
||||||
"a single-family tcpudp port has no dual-stack CPORTS form")
|
"a single-family tcpudp port has no dual-stack CPORTS form")
|
||||||
// An address-less multi-port accept is native: the CPORTS lists are comma lists.
|
// An address-less multi-port accept is hooked: a CPORTS entry reads back one
|
||||||
// The same ports against an address are not — apf's advanced rule holds one port.
|
// rule per token, so the hook's multiport line is the only round-tripping form.
|
||||||
require.False(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}),
|
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}),
|
||||||
"a dual-stack tcpudp port list lives in the CPORTS lists")
|
"a multi-port list must route to the hook's multiport match")
|
||||||
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}},
|
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}},
|
||||||
Source: "192.0.2.1", Action: Accept}),
|
Source: "192.0.2.1", Action: Accept}),
|
||||||
"apf's advanced rule carries no port list")
|
"apf's advanced rule carries no port list")
|
||||||
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, SourcePort: 80, Action: Accept}),
|
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, SourcePort: 80, Action: Accept}),
|
||||||
"an address-less source-port match has no advanced-rule form")
|
"an address-less source-port match has no advanced-rule form")
|
||||||
// A portless tcpudp host has no plain-line form (a plain line is all-protocol).
|
// A portless tcpudp host has no plain-line form (a plain line is all-protocol).
|
||||||
require.True(t, hostNeedsHook(&Rule{Proto: TCPUDP, Source: "192.0.2.1", Action: Accept}))
|
require.True(t, shapeNeedsHook(&Rule{Proto: TCPUDP, Source: "192.0.2.1", Action: Accept}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// With conf.apf's USE_IPV6 off, apf installs no IPv6 rule from its config, so a
|
// With conf.apf's USE_IPV6 off, apf installs no IPv6 rule from its config, so a
|
||||||
|
|
@ -1023,3 +1037,52 @@ func TestAPFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
|
||||||
require.NotContains(t, string(data), "d=80",
|
require.NotContains(t, string(data), "d=80",
|
||||||
"removal must sweep the stale IPv6 line even with IPv6 off")
|
"removal must sweep the stale IPv6 line even with IPv6 off")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With USE_IPV6 off a family-agnostic NAT rule is written for IPv4 only and a
|
||||||
|
// concrete-IPv6 one is rejected outright; removal still sweeps both families so
|
||||||
|
// a stale v6 line does not survive an IPv6 switch-off.
|
||||||
|
func TestAPFNATIPv6Gating(t *testing.T) {
|
||||||
|
off := new(APF)
|
||||||
|
masq := &NATRule{Kind: Masquerade, Interface: "eth0"}
|
||||||
|
require.Equal(t, []Family{IPv4}, natWriteFamilies(off.ipv6Enabled, masq),
|
||||||
|
"a family-agnostic write must narrow to IPv4 while IPv6 is off")
|
||||||
|
require.Equal(t, []Family{IPv4, IPv6}, natRuleFamilies(masq),
|
||||||
|
"removal still sweeps both families")
|
||||||
|
|
||||||
|
err := off.AddNATRule(context.Background(), "", &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 8080, ToAddress: "2001:db8::5"})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedNAT, "a concrete-IPv6 nat add must be rejected while IPv6 is off")
|
||||||
|
|
||||||
|
on := &APF{ipv6Enabled: true}
|
||||||
|
require.Equal(t, []Family{IPv4, IPv6}, natWriteFamilies(on.ipv6Enabled, masq))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRule of an iptables-inexpressible shape must reject with the sentinel
|
||||||
|
// before sweeping the hook, mirroring AddRule.
|
||||||
|
func TestAPFRemoveRuleUnsupportedShapeSentinel(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
err := fw.RemoveRule(context.Background(), "", &Rule{Proto: ProtocolAny, Port: 80, Action: Accept})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// confKeyApplies mirrors EditRulePort's routing guards; EditConf keys its
|
||||||
|
// missing-config-line detection on it.
|
||||||
|
func TestAPFConfKeyApplies(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
port := &Rule{Proto: TCP, Port: 80, Action: Accept}
|
||||||
|
require.True(t, fw.confKeyApplies("IG_TCP_CPORTS", port))
|
||||||
|
require.False(t, fw.confKeyApplies("IG_UDP_CPORTS", port))
|
||||||
|
require.False(t, fw.confKeyApplies("EG_TCP_CPORTS", port))
|
||||||
|
|
||||||
|
both := &Rule{Proto: TCPUDP, Port: 53, Action: Accept}
|
||||||
|
require.True(t, fw.confKeyApplies("IG_TCP_CPORTS", both))
|
||||||
|
require.True(t, fw.confKeyApplies("IG_UDP_CPORTS", both))
|
||||||
|
|
||||||
|
icmp6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}
|
||||||
|
require.True(t, fw.confKeyApplies("IG_ICMPV6_TYPES", icmp6))
|
||||||
|
require.False(t, fw.confKeyApplies("IG_ICMP_TYPES", icmp6))
|
||||||
|
|
||||||
|
climit := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}
|
||||||
|
require.True(t, fw.confKeyApplies("IG_TCP_CLIMIT", climit))
|
||||||
|
require.False(t, fw.confKeyApplies("IG_TCP_CPORTS", climit),
|
||||||
|
"a connlimit rule must never touch the accept port lists")
|
||||||
|
}
|
||||||
|
|
|
||||||
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").
|
// MarshalJSON renders the NAT kind as its stable name (e.g. "dnat").
|
||||||
func (k NATKind) MarshalJSON() ([]byte, error) { return marshalEnum(k, NATKind.String) }
|
func (k NATKind) MarshalJSON() ([]byte, error) { return marshalEnum(k, NATKind.String) }
|
||||||
|
|
||||||
// UnmarshalJSON parses a NAT kind from its stable name.
|
// UnmarshalJSON parses a NAT kind from its stable name. The sentinel "invalid"
|
||||||
|
// (NATInvalid) is accepted on the wire for round-trip fidelity even though
|
||||||
|
// ParseNATKind rejects it as caller input, mirroring Action.
|
||||||
func (k *NATKind) UnmarshalJSON(data []byte) error {
|
func (k *NATKind) UnmarshalJSON(data []byte) error {
|
||||||
v, err := unmarshalEnum(data, ParseNATKind)
|
v, err := unmarshalEnum(data, func(s string) (NATKind, error) {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(s), "invalid") {
|
||||||
|
return NATInvalid, nil
|
||||||
|
}
|
||||||
|
return ParseNATKind(s)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -158,11 +165,15 @@ func captureBackupState(ctx context.Context, mgr Manager, zoneName string, b *Ba
|
||||||
if caps.DefaultPolicy {
|
if caps.DefaultPolicy {
|
||||||
// Best-effort: a backend that cannot report a single coherent default policy
|
// Best-effort: a backend that cannot report a single coherent default policy
|
||||||
// (iptables with out-of-band divergent IPv4/IPv6 chain policies) captures none
|
// (iptables with out-of-band divergent IPv4/IPv6 chain policies) captures none
|
||||||
// rather than failing the whole backup. A genuine read error would already have
|
// rather than failing the whole backup, and Restore then leaves the policy as
|
||||||
// surfaced from the GetRules call the Backup makes before this, which reads the
|
// it finds it. A cancelled context is not that case — it would silently
|
||||||
// same source; leaving the field nil then makes Restore leave the policy as it
|
// produce a snapshot missing the policy a default-drop host depends on — so
|
||||||
// finds it.
|
// it still fails the backup.
|
||||||
if policy, err := mgr.GetDefaultPolicy(ctx, zoneName); err == nil {
|
policy, err := mgr.GetDefaultPolicy(ctx, zoneName)
|
||||||
|
if err != nil && ctx.Err() != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
b.DefaultPolicy = policy
|
b.DefaultPolicy = policy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +196,7 @@ func captureBackupState(ctx context.Context, mgr Manager, zoneName string, b *Ba
|
||||||
// AddAddressSet is a no-op on an existing set and so would not reconcile its
|
// AddAddressSet is a no-op on an existing set and so would not reconcile its
|
||||||
// entries. A caller whose old rules are still loaded when sets are recreated
|
// entries. A caller whose old rules are still loaded when sets are recreated
|
||||||
// (a tag/rewrite backend) passes false and relies on AddAddressSet's own
|
// (a tag/rewrite backend) passes false and relies on AddAddressSet's own
|
||||||
// idempotent create-or-repopulate (ipset -exist, pfctl -T add).
|
// idempotent create-or-reconcile (ipset -exist, pfctl -T replace).
|
||||||
func restoreBackupSets(ctx context.Context, mgr Manager, b *Backup, cleanFirst bool) error {
|
func restoreBackupSets(ctx context.Context, mgr Manager, b *Backup, cleanFirst bool) error {
|
||||||
if b == nil || !mgr.Capabilities().AddressSets {
|
if b == nil || !mgr.Capabilities().AddressSets {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect
|
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/google/cabbie v1.0.2 // indirect
|
github.com/google/cabbie v1.0.2 // indirect
|
||||||
|
|
@ -27,8 +27,3 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
replace git.gec.im/GRMrGecko/go-firewall => ../..
|
replace git.gec.im/GRMrGecko/go-firewall => ../..
|
||||||
|
|
||||||
// Mirrors the library's own replace: the old coreos/go-systemd import path is
|
|
||||||
// redirected to the maintained v22 module (the v0 release does not compile
|
|
||||||
// against modern godbus). Kept here so the CLI is self-contained when built.
|
|
||||||
replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,12 @@ type atomicFile struct {
|
||||||
// to the returned handle, then calls Commit to install it or Abort to discard
|
// to the returned handle, then calls Commit to install it or Abort to discard
|
||||||
// it. Capturing the destination's mode and ownership here means a later
|
// it. Capturing the destination's mode and ownership here means a later
|
||||||
// scan-and-rewrite of the original still commits with the original's metadata.
|
// scan-and-rewrite of the original still commits with the original's metadata.
|
||||||
|
// A symlinked destination is resolved so the rename replaces the link's target
|
||||||
|
// rather than turning the link itself into a regular file.
|
||||||
func newAtomicFile(dst string, defaultMode os.FileMode) (*atomicFile, error) {
|
func newAtomicFile(dst string, defaultMode os.FileMode) (*atomicFile, error) {
|
||||||
|
if resolved, err := filepath.EvalSymlinks(dst); err == nil {
|
||||||
|
dst = resolved
|
||||||
|
}
|
||||||
mode := defaultMode
|
mode := defaultMode
|
||||||
var uid, gid int
|
var uid, gid int
|
||||||
var chown bool
|
var chown bool
|
||||||
|
|
@ -62,15 +67,21 @@ func (a *atomicFile) Commit() error {
|
||||||
}
|
}
|
||||||
// Apply mode and ownership through the fd so the staged file is never
|
// Apply mode and ownership through the fd so the staged file is never
|
||||||
// briefly installed with the wrong permissions.
|
// briefly installed with the wrong permissions.
|
||||||
if err := a.File.Chmod(a.mode); err != nil {
|
if err := a.Chmod(a.mode); err != nil {
|
||||||
return a.fail(err)
|
return a.fail(err)
|
||||||
}
|
}
|
||||||
if a.chown {
|
if a.chown {
|
||||||
if err := a.File.Chown(a.uid, a.gid); err != nil && !errors.Is(err, os.ErrPermission) {
|
if err := a.Chown(a.uid, a.gid); err != nil && !errors.Is(err, os.ErrPermission) {
|
||||||
return a.fail(err)
|
return a.fail(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := a.File.Close(); err != nil {
|
// Sync before the rename: these files are reboot-persistence-critical
|
||||||
|
// (csf.conf, iptables save files), and a crash could otherwise reorder the
|
||||||
|
// rename ahead of the data reaching disk, installing a truncated config.
|
||||||
|
if err := a.Sync(); err != nil {
|
||||||
|
return a.fail(err)
|
||||||
|
}
|
||||||
|
if err := a.Close(); err != nil {
|
||||||
return a.fail(err)
|
return a.fail(err)
|
||||||
}
|
}
|
||||||
// Install the staged file.
|
// Install the staged file.
|
||||||
|
|
@ -89,14 +100,14 @@ func (a *atomicFile) Abort() {
|
||||||
if a.done {
|
if a.done {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = a.File.Close()
|
_ = a.Close()
|
||||||
_ = os.Remove(a.tmp)
|
_ = os.Remove(a.tmp)
|
||||||
a.done = true
|
a.done = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// fail closes and removes the temp file, then returns the triggering error.
|
// fail closes and removes the temp file, then returns the triggering error.
|
||||||
func (a *atomicFile) fail(err error) error {
|
func (a *atomicFile) fail(err error) error {
|
||||||
_ = a.File.Close()
|
_ = a.Close()
|
||||||
_ = os.Remove(a.tmp)
|
_ = os.Remove(a.tmp)
|
||||||
a.done = true
|
a.done = true
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -155,3 +155,28 @@ func TestAtomicFileStagesInDestinationDir(t *testing.T) {
|
||||||
require.True(t, strings.HasPrefix(filepath.Base(af.tmp), "conf.tmp."))
|
require.True(t, strings.HasPrefix(filepath.Base(af.tmp), "conf.tmp."))
|
||||||
af.Abort()
|
af.Abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readConfValue must not truncate a quoted value containing '#', and the last
|
||||||
|
// assignment of a key wins, matching the shell that sources these files.
|
||||||
|
func TestReadConfValueQuotingAndLastAssignment(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "conf")
|
||||||
|
body := `
|
||||||
|
# A comment line.
|
||||||
|
PREFIX = "pre#fix" # trailing comment
|
||||||
|
USE_IPV6 = "0"
|
||||||
|
USE_IPV6 = "1"
|
||||||
|
`
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(body), 0644))
|
||||||
|
|
||||||
|
v, err := readConfValue(path, "PREFIX")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "pre#fix", v, "a '#' inside quotes is part of the value")
|
||||||
|
|
||||||
|
v, err = readConfValue(path, "USE_IPV6")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "1", v, "the last assignment wins")
|
||||||
|
|
||||||
|
v, err = readConfValue(path, "MISSING")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "", v)
|
||||||
|
}
|
||||||
|
|
|
||||||
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 {
|
if _, err := os.Stat("/etc/csf/csf.disable"); err == nil {
|
||||||
return nil, fmt.Errorf("csf is currently disabled")
|
return nil, fmt.Errorf("csf is currently disabled")
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +78,6 @@ func NewCSF(ctx context.Context, rulePrefix string) (*CSF, error) {
|
||||||
}
|
}
|
||||||
csf.ipv6Enabled = useIPv6 == "1"
|
csf.ipv6Enabled = useIPv6 == "1"
|
||||||
|
|
||||||
// Return the new csf object.
|
|
||||||
return csf, nil
|
return csf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,23 +95,31 @@ func (f *CSF) Capabilities() Capabilities {
|
||||||
// ip6tables, so neither its native config nor the raw-iptables hook yields a
|
// ip6tables, so neither its native config nor the raw-iptables hook yields a
|
||||||
// rule csf will keep in sync across a reload (see ipv6Unavailable).
|
// rule csf will keep in sync across a reload (see ipv6Unavailable).
|
||||||
ICMPv6: f.ipv6Enabled,
|
ICMPv6: f.ipv6Enabled,
|
||||||
// A csf.conf port list (TCP_IN="80,443,...") stores each port independently
|
// PortList is honored through the pre-hook: a csf.conf port list
|
||||||
// and reads back as one rule per port, so a discrete multi-port rule does
|
// (TCP_IN="80,443,...") stores each port independently and reads back as
|
||||||
// not round-trip as a single rule (a range, kept as one token, does). Report
|
// one rule per port, so an address-less multi-port accept is injected as a
|
||||||
// PortList as unsupported to reflect that; callers open several ports by
|
// single `-m multiport` iptables line instead (see multiPortConfAccept).
|
||||||
// adding a rule per port, which is how csf stores them anyway.
|
// An addressed multi-port rule is native: an advanced line's port field
|
||||||
PortList: false,
|
// is a comma list.
|
||||||
ConnState: true,
|
PortList: true,
|
||||||
InterfaceMatch: true,
|
PortPair: true,
|
||||||
Logging: true,
|
ConnState: true,
|
||||||
RateLimit: true,
|
InterfaceMatch: true,
|
||||||
ConnLimit: true,
|
Logging: true,
|
||||||
NAT: true,
|
RateLimit: true,
|
||||||
RuleOrdering: false,
|
ConnLimit: true,
|
||||||
DefaultPolicy: false,
|
NAT: true,
|
||||||
RuleCounters: false,
|
RuleOrdering: false,
|
||||||
AddressSets: true,
|
DefaultPolicy: false,
|
||||||
Comments: true,
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,32 +359,16 @@ func (f *CSF) ParseIPList(filePath string, action Action) (rules []*Rule, err er
|
||||||
|
|
||||||
comment, hasPrefix := flushComment()
|
comment, hasPrefix := flushComment()
|
||||||
|
|
||||||
if strings.Contains(trimmed, "|") {
|
// parseListLine classifies an advanced line (pipe or colon delimited) or a
|
||||||
rule := f.ParseAdvRule(trimmed, action)
|
// plain address line — one bidirectional DirAny rule authored in the
|
||||||
if rule == nil {
|
// inbound frame (Source=X) — and passes anything else through untouched.
|
||||||
continue
|
rule := f.parseListLine(trimmed, action)
|
||||||
}
|
if rule == nil {
|
||||||
rule.Comment = comment
|
continue
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
rule.Comment = comment
|
||||||
|
rule.HasPrefix = hasPrefix
|
||||||
|
rules = append(rules, rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = fd.Close()
|
_ = fd.Close()
|
||||||
|
|
@ -509,14 +508,19 @@ func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err
|
||||||
rules = append(rules, f.ParsePorts(val, IPv4, UDP, false)...)
|
rules = append(rules, f.ParsePorts(val, IPv4, UDP, false)...)
|
||||||
case "UDP_OUT":
|
case "UDP_OUT":
|
||||||
rules = append(rules, f.ParsePorts(val, IPv4, UDP, true)...)
|
rules = append(rules, f.ParsePorts(val, IPv4, UDP, true)...)
|
||||||
case "TCP6_IN":
|
case "TCP6_IN", "TCP6_OUT", "UDP6_IN", "UDP6_OUT":
|
||||||
rules = append(rules, f.ParsePorts(val, IPv6, TCP, false)...)
|
// With IPV6 off csf never applies the *6_* lists, so their entries
|
||||||
case "TCP6_OUT":
|
// are inert; reporting them would claim IPv6 coverage the firewall
|
||||||
rules = append(rules, f.ParsePorts(val, IPv6, TCP, true)...)
|
// does not enforce.
|
||||||
case "UDP6_IN":
|
if !f.ipv6Enabled {
|
||||||
rules = append(rules, f.ParsePorts(val, IPv6, UDP, false)...)
|
continue
|
||||||
case "UDP6_OUT":
|
}
|
||||||
rules = append(rules, f.ParsePorts(val, IPv6, UDP, true)...)
|
proto := TCP
|
||||||
|
if strings.HasPrefix(key, "UDP") {
|
||||||
|
proto = UDP
|
||||||
|
}
|
||||||
|
out := strings.HasSuffix(key, "_OUT")
|
||||||
|
rules = append(rules, f.ParsePorts(val, IPv6, proto, out)...)
|
||||||
case "CONNLIMIT":
|
case "CONNLIMIT":
|
||||||
rules = append(rules, f.ParseConnLimit(val)...)
|
rules = append(rules, f.ParseConnLimit(val)...)
|
||||||
}
|
}
|
||||||
|
|
@ -539,6 +543,8 @@ func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err
|
||||||
// with the action its direction actually gets rather than a fixed Reject —
|
// with the action its direction actually gets rather than a fixed Reject —
|
||||||
// otherwise a Drop rule the caller manages reads back as Reject, never
|
// otherwise a Drop rule the caller manages reads back as Reject, never
|
||||||
// compares equal to the desired rule, and churns on every Sync.
|
// compares equal to the desired rule, and churns on every Sync.
|
||||||
|
// LF_BLOCKINONLY (off in stock csf.conf) is not modeled: with it set, csf
|
||||||
|
// skips a deny line's outbound rule while the line still reads back DirAny.
|
||||||
dropIn, dropOut := f.dropActions()
|
dropIn, dropOut := f.dropActions()
|
||||||
ipRules, err = f.ParseIPList(CSFDeny, dropIn)
|
ipRules, err = f.ParseIPList(CSFDeny, dropIn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -642,18 +648,46 @@ func (f *CSF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
|
||||||
if r.IsOutput() || r.Family == IPv4 || r.Proto == UDP {
|
if r.IsOutput() || r.Family == IPv4 || r.Proto == UDP {
|
||||||
return orig
|
return orig
|
||||||
}
|
}
|
||||||
|
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
|
||||||
|
// With IPV6 off a family-agnostic add is written for IPv4 only (the
|
||||||
|
// v6 entry would sit inert and read back as unenforced coverage);
|
||||||
|
// removals still sweep, and a concrete-IPv6 row written by Restore
|
||||||
|
// keeps its family. The list-file analog is writeFamilyRows.
|
||||||
|
return orig
|
||||||
|
}
|
||||||
case "TCP6_OUT":
|
case "TCP6_OUT":
|
||||||
if !r.IsOutput() || r.Family == IPv4 || r.Proto == UDP {
|
if !r.IsOutput() || r.Family == IPv4 || r.Proto == UDP {
|
||||||
return orig
|
return orig
|
||||||
}
|
}
|
||||||
|
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
|
||||||
|
// With IPV6 off a family-agnostic add is written for IPv4 only (the
|
||||||
|
// v6 entry would sit inert and read back as unenforced coverage);
|
||||||
|
// removals still sweep, and a concrete-IPv6 row written by Restore
|
||||||
|
// keeps its family. The list-file analog is writeFamilyRows.
|
||||||
|
return orig
|
||||||
|
}
|
||||||
case "UDP6_IN":
|
case "UDP6_IN":
|
||||||
if r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
|
if r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
|
||||||
return orig
|
return orig
|
||||||
}
|
}
|
||||||
|
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
|
||||||
|
// With IPV6 off a family-agnostic add is written for IPv4 only (the
|
||||||
|
// v6 entry would sit inert and read back as unenforced coverage);
|
||||||
|
// removals still sweep, and a concrete-IPv6 row written by Restore
|
||||||
|
// keeps its family. The list-file analog is writeFamilyRows.
|
||||||
|
return orig
|
||||||
|
}
|
||||||
case "UDP6_OUT":
|
case "UDP6_OUT":
|
||||||
if !r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
|
if !r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
|
||||||
return orig
|
return orig
|
||||||
}
|
}
|
||||||
|
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
|
||||||
|
// With IPV6 off a family-agnostic add is written for IPv4 only (the
|
||||||
|
// v6 entry would sit inert and read back as unenforced coverage);
|
||||||
|
// removals still sweep, and a concrete-IPv6 row written by Restore
|
||||||
|
// keeps its family. The list-file analog is writeFamilyRows.
|
||||||
|
return orig
|
||||||
|
}
|
||||||
case "CONNLIMIT":
|
case "CONNLIMIT":
|
||||||
// CONNLIMIT tokens are "port;limit", edited independently of the port
|
// CONNLIMIT tokens are "port;limit", edited independently of the port
|
||||||
// lists above.
|
// lists above.
|
||||||
|
|
@ -707,7 +741,8 @@ func (f *CSF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
|
||||||
|
|
||||||
// EditConf rewrites csf.conf to add or remove a port-list or CONNLIMIT rule.
|
// EditConf rewrites csf.conf to add or remove a port-list or CONNLIMIT rule.
|
||||||
func (f *CSF) EditConf(ctx context.Context, r *Rule, remove bool) error {
|
func (f *CSF) EditConf(ctx context.Context, r *Rule, remove bool) error {
|
||||||
// For port only rules, open the standard config file.
|
// Open the standard config file; EditRulePort rewrites the port-list and
|
||||||
|
// CONNLIMIT lines the rule applies to.
|
||||||
fd, err := os.Open(CSFConf)
|
fd, err := os.Open(CSFConf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -726,7 +761,7 @@ func (f *CSF) EditConf(ctx context.Context, r *Rule, remove bool) error {
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
// Get the line.
|
// Get the line.
|
||||||
orig := scanner.Text()
|
orig := scanner.Text()
|
||||||
line := orig[:]
|
line := orig
|
||||||
|
|
||||||
// Remove comments.
|
// Remove comments.
|
||||||
ci := strings.IndexByte(line, '#')
|
ci := strings.IndexByte(line, '#')
|
||||||
|
|
@ -834,11 +869,17 @@ func (f *CSF) parseListLine(line string, action Action) *Rule {
|
||||||
if strings.Contains(line, "|") {
|
if strings.Contains(line, "|") {
|
||||||
return f.ParseAdvRule(line, action)
|
return f.ParseAdvRule(line, action)
|
||||||
}
|
}
|
||||||
family, ok := parseAddrFamily(line)
|
if family, ok := parseAddrFamily(line); ok {
|
||||||
if !ok {
|
return &Rule{Direction: DirAny, Family: family, Source: line, Action: action}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return &Rule{Direction: DirAny, Family: family, Source: line, Action: action}
|
// csf.pl accepts a colon-delimited advanced line, converting `:` to `|` when
|
||||||
|
// the line carries no pipe; an address (IPv6 included) was classified above,
|
||||||
|
// so the conversion never touches one. An advanced line always carries an s=
|
||||||
|
// or d= field, which keeps other unparseable colon text opaque.
|
||||||
|
if strings.Contains(line, ":") && strings.Contains(line, "=") {
|
||||||
|
return f.ParseAdvRule(strings.ReplaceAll(line, ":", "|"), action)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// listRows returns the csf.allow/csf.deny rows a rule materializes into, in write
|
// listRows returns the csf.allow/csf.deny rows a rule materializes into, in write
|
||||||
|
|
@ -869,7 +910,7 @@ func (f *CSF) listRows(action Action, match *Rule) []listRow {
|
||||||
// A bare all-protocol host allow/deny: a single address matching every
|
// A bare all-protocol host allow/deny: a single address matching every
|
||||||
// protocol. csf.allow/csf.deny hold no other portless address shape — a
|
// protocol. csf.allow/csf.deny hold no other portless address shape — a
|
||||||
// concrete-protocol host or a source+destination pair — so AddRule diverts
|
// concrete-protocol host or a source+destination pair — so AddRule diverts
|
||||||
// those to the raw-iptables hook (hostNeedsHook) and never reaches here with
|
// those to the raw-iptables hook (shapeNeedsHook) and never reaches here with
|
||||||
// one. A direct caller of this exported writer that supplies such a shape gets
|
// one. A direct caller of this exported writer that supplies such a shape gets
|
||||||
// a best-effort single-address write, not a guard.
|
// a best-effort single-address write, not a guard.
|
||||||
addr := match.Source
|
addr := match.Source
|
||||||
|
|
@ -890,7 +931,7 @@ func (f *CSF) listRows(action Action, match *Rule) []listRow {
|
||||||
}
|
}
|
||||||
for _, sub := range expandProtocols(fam) {
|
for _, sub := range expandProtocols(fam) {
|
||||||
// The row reads back address-less; only the line carries the placeholder.
|
// The row reads back address-less; only the line carries the placeholder.
|
||||||
// advRuleNeedsHook has already routed an address-less source-port match to
|
// shapeNeedsHook has already routed an address-less source-port match to
|
||||||
// the hook, so the address field being filled in here is free.
|
// the hook, so the address field being filled in here is free.
|
||||||
row := *sub
|
row := *sub
|
||||||
if row.IsOutput() {
|
if row.IsOutput() {
|
||||||
|
|
@ -1051,26 +1092,13 @@ func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r
|
||||||
return af.Commit()
|
return af.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// nativeICMP reports whether an ICMPv4 rule maps onto a csf advanced rule and so
|
|
||||||
// belongs in csf.allow/csf.deny rather than the raw-iptables hook. An advanced line
|
|
||||||
// requires an address, and its single port-flow field carries the icmp type, so only
|
|
||||||
// a rule with exactly one address and a concrete type has a faithful form: csf.pl's
|
|
||||||
// linefilter reads that field by position, and an address with no type would land the
|
|
||||||
// address there (`--icmp-type <ip>`), which csf fails to parse and drops silently. A
|
|
||||||
// source+destination pair overflows the single address field and is routed by
|
|
||||||
// advRuleNeedsHook. ICMPv6 never reaches this test — ruleNeedsHook sends it to the
|
|
||||||
// hook, or the IPv6 gate rejects it.
|
|
||||||
func (f *CSF) nativeICMP(r *Rule) bool {
|
|
||||||
return r.Proto == ICMP && r.ICMPType != nil && (r.Source != "") != (r.Destination != "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// needsHook reports whether a rule must be injected through the csf pre-hook as a
|
// needsHook reports whether a rule must be injected through the csf pre-hook as a
|
||||||
// raw iptables rule because csf's native config cannot express it. It is the single
|
// raw iptables rule because csf's native config cannot express it. It is the single
|
||||||
// gate between the hook path and csf's config files: everything it rejects (returns
|
// gate between the hook path and csf's config files: everything it rejects (returns
|
||||||
// true) is written to the hook, everything it accepts (returns false) maps onto
|
// true) is written to the hook, everything it accepts (returns false) maps onto
|
||||||
// csf.conf or the csf.allow/csf.deny lists. The shared shapes (ruleNeedsHook,
|
// csf.conf or the csf.allow/csf.deny lists. The shared predicates (ruleNeedsHook,
|
||||||
// bareHostOneWay, hostNeedsHook, advRuleNeedsHook, bareProtoNeedsHook) keep their own
|
// shapeNeedsHook, bareHostOneWay) stay standalone, since APF shares them and
|
||||||
// predicates, since RemoveRule routes on ruleNeedsHook and bareHostOneWay directly.
|
// RemoveRule routes on ruleNeedsHook and bareHostOneWay directly.
|
||||||
func (f *CSF) needsHook(r *Rule) bool {
|
func (f *CSF) needsHook(r *Rule) bool {
|
||||||
// Features csf's native config cannot express (connection state, per-rule
|
// Features csf's native config cannot express (connection state, per-rule
|
||||||
// interface, logging, rate limiting, forward-chain routing, icmpv6, a transport
|
// interface, logging, rate limiting, forward-chain routing, icmpv6, a transport
|
||||||
|
|
@ -1078,17 +1106,18 @@ func (f *CSF) needsHook(r *Rule) bool {
|
||||||
if ruleNeedsHook(r) {
|
if ruleNeedsHook(r) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// A one-way bare host has no csf.allow/csf.deny form (a plain line is
|
// A shape no native csf form holds — a one-way bare host, a source+destination
|
||||||
// bidirectional, an advanced rule needs a port), and a concrete-protocol host or a
|
// pair, a concrete-protocol portless host, an advanced-line address/port-flow
|
||||||
// source+destination pair likewise has none (see hostNeedsHook); all go to the hook.
|
// overflow, or a bare protocol match (see shapeNeedsHook) — goes to the hook.
|
||||||
if bareHostOneWay(r) || hostNeedsHook(r) {
|
if shapeNeedsHook(r) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// A ported source+destination pair, a source port matched with a destination port,
|
// An address-less multi-port accept goes to the hook's `-m multiport` match:
|
||||||
// and an address-less source-port match all overflow the advanced line's single
|
// a csf.conf port list stores each port as an independent token that reads
|
||||||
// address and single port-flow field (see advRuleNeedsHook); iptables matches each
|
// back as its own rule, so the list shape has no single native form (see
|
||||||
// directly, so they go to the hook.
|
// multiPortConfAccept). An addressed multi-port rule stays native — an
|
||||||
if advRuleNeedsHook(r) {
|
// advanced line's port field is a comma list.
|
||||||
|
if f.multiPortConfAccept(r) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// A connection limit csf.conf's CONNLIMIT cannot express — anything but a
|
// A connection limit csf.conf's CONNLIMIT cannot express — anything but a
|
||||||
|
|
@ -1097,16 +1126,32 @@ func (f *CSF) needsHook(r *Rule) bool {
|
||||||
if r.ConnLimit != nil && !f.isConnLimitRule(r) {
|
if r.ConnLimit != nil && !f.isConnLimitRule(r) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// An ICMPv4 rule csf's advanced-rule format cannot carry (see nativeICMP) goes to
|
// An ICMPv4 rule csf's advanced-rule format cannot carry goes to the hook's
|
||||||
// the hook's `iptables -p icmp` match, which needs neither an address nor a type.
|
// `iptables -p icmp` match, which needs neither an address nor a type. The only
|
||||||
if r.Proto == ICMP && !f.nativeICMP(r) {
|
// native form is exactly one address with a concrete type: an advanced line
|
||||||
return true
|
// requires an address, its single port-flow field carries the icmp type, and
|
||||||
}
|
// csf.pl's linefilter reads that field by position — an address with no type
|
||||||
// A bare protocol match with no address and no port has no native csf construct —
|
// would land the address there (`--icmp-type <ip>`), which csf fails to parse
|
||||||
// csf.conf keys on a port, an advanced rule on address+port, and csf.allow/csf.deny
|
// and drops silently. A source+destination pair overflows the single address
|
||||||
// on an address — but iptables expresses it directly, so it goes to the hook (see
|
// field and is routed by shapeNeedsHook above; ICMPv6 never reaches this test —
|
||||||
// bareProtoNeedsHook). ICMP is excluded there and routed above.
|
// ruleNeedsHook sends it to the hook, or the IPv6 gate rejects it.
|
||||||
return bareProtoNeedsHook(r)
|
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,
|
// denyAction returns the action a csf.deny entry takes in the given direction,
|
||||||
|
|
@ -1168,10 +1213,11 @@ func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6
|
||||||
|
|
||||||
// Any shape csf's native config cannot express (a stateful/interface/logged/
|
// Any shape csf's native config cannot express (a stateful/interface/logged/
|
||||||
// rate-limited rule, a one-way or concrete-protocol host, a source+destination
|
// rate-limited rule, a one-way or concrete-protocol host, a source+destination
|
||||||
// pair, a source-and-destination port match, an address-less source-port match, a
|
// pair, a source-and-destination port match, an address-less source-port match,
|
||||||
// non-native connection limit, a non-native ICMPv4 rule, or a bare protocol match)
|
// an address-less multi-port accept, a non-native connection limit, a non-native
|
||||||
// is injected as a raw iptables rule through the csf pre-hook. See needsHook for
|
// ICMPv4 rule, or a bare protocol match) is injected as a raw iptables rule
|
||||||
// each clause; everything past this gate maps onto csf's own config files.
|
// through the csf pre-hook. See needsHook for each clause; everything past this
|
||||||
|
// gate maps onto csf's own config files.
|
||||||
if f.needsHook(r) {
|
if f.needsHook(r) {
|
||||||
_, err := f.hook().edit(r, false)
|
_, err := f.hook().edit(r, false)
|
||||||
return err
|
return err
|
||||||
|
|
@ -1182,12 +1228,12 @@ func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6
|
||||||
return f.EditConf(ctx, r, false)
|
return f.EditConf(ctx, r, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A port-only accept maps to a csf.conf port list rather than csf.allow.
|
// A port-only accept maps to a csf.conf port list rather than csf.allow;
|
||||||
|
// listRows has no row for it, so falling through to the csf.allow edit would
|
||||||
|
// only rewrite that file byte-identically. Only a single port or one range
|
||||||
|
// reaches here — needsHook diverted a multi-port list to the hook above.
|
||||||
if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept {
|
if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept {
|
||||||
err := f.EditConf(ctx, r, false)
|
return f.EditConf(ctx, r, false)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny
|
// Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny
|
||||||
|
|
@ -1265,6 +1311,13 @@ func (f *CSF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if s := splitDualRowDirection(e, r); s != nil {
|
if s := splitDualRowDirection(e, r); s != nil {
|
||||||
|
// A deny plain line's outbound half was enforced with csf.conf's
|
||||||
|
// DROP_OUT action, while the DirAny row reads back with the inbound
|
||||||
|
// action; re-express the survivor with the action csf actually applied
|
||||||
|
// so the split does not silently change wire behavior.
|
||||||
|
if s.Action != Accept && s.IsOutput() {
|
||||||
|
_, s.Action = f.dropActions()
|
||||||
|
}
|
||||||
_, err := f.hook().edit(s, false)
|
_, err := f.hook().edit(s, false)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1301,6 +1354,14 @@ func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the shape before the hook sweep below: an iptables-inexpressible
|
||||||
|
// rule (a port on ProtocolAny) exists nowhere csf can hold it, and letting it
|
||||||
|
// fail inside the hook marshal would return the bare error without the
|
||||||
|
// sentinel AddRule attaches to the same shape.
|
||||||
|
if err := iptablesRuleValid(r); err != nil {
|
||||||
|
return fmt.Errorf("%v: %w", err, ErrUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
// Clear any hook copy of the rule first, no matter how csf stores it. A rule csf
|
// Clear any hook copy of the rule first, no matter how csf stores it. A rule csf
|
||||||
// carries only in the hook (see needsHook) lives nowhere else, so this is its
|
// carries only in the hook (see needsHook) lives nowhere else, so this is its
|
||||||
// entire removal; a natively-expressible rule may still have a stray hook copy —
|
// entire removal; a natively-expressible rule may still have a stray hook copy —
|
||||||
|
|
@ -1331,12 +1392,12 @@ func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
}
|
}
|
||||||
// Every other shape csf's native config cannot express (see needsHook) has already
|
// Every other shape csf's native config cannot express (see needsHook) has already
|
||||||
// had its hook copy cleared above and has no native entry to split, so it is done.
|
// had its hook copy cleared above and has no native entry to split, so it is done.
|
||||||
if f.needsHook(r) {
|
// An address-less multi-port accept is the exception: it lives in the hook now,
|
||||||
|
// but an earlier per-port add (or a manual edit) may also hold its ports as
|
||||||
|
// csf.conf tokens, so it falls through to the port-list sweep below.
|
||||||
|
if f.needsHook(r) && !f.multiPortConfAccept(r) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := iptablesRuleValid(r); err != nil {
|
|
||||||
return fmt.Errorf("%v: %w", err, ErrUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A native connection-limit rule maps onto the csf.conf CONNLIMIT list.
|
// A native connection-limit rule maps onto the csf.conf CONNLIMIT list.
|
||||||
if r.ConnLimit != nil {
|
if r.ConnLimit != nil {
|
||||||
|
|
@ -1433,40 +1494,82 @@ func (f *CSF) UnmarshalNATRule(line string) *NATRule {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNATRules reads the NAT rules from csf.redirect.
|
// natNeedsHook reports whether a NAT rule has no csf.redirect form and must be
|
||||||
func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
|
// injected as a raw nat-table command through the pre-hook instead. csf.redirect
|
||||||
fd, err := os.Open(CSFRedirect)
|
// encodes destination NAT only, in exactly two DNAT shapes plus the local
|
||||||
if err != nil {
|
// redirect, always tcp/udp on a single concrete port, with no source or
|
||||||
// csf.redirect is optional; a missing file simply has no rules.
|
// interface match (see MarshalNATRule). iptables expresses all of the excess —
|
||||||
if os.IsNotExist(err) {
|
// source NAT, an interface binding, a port range — directly, so those shapes are
|
||||||
return nil, nil
|
// hooked rather than rejected.
|
||||||
}
|
func (f *CSF) natNeedsHook(r *NATRule) bool {
|
||||||
return nil, err
|
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
|
var rules []*NATRule
|
||||||
scanner := bufio.NewScanner(fd)
|
fd, err := os.Open(CSFRedirect)
|
||||||
for scanner.Scan() {
|
switch {
|
||||||
line := scanner.Text()
|
case err == nil:
|
||||||
if ci := strings.IndexByte(line, '#'); ci >= 0 {
|
defer func() { _ = fd.Close() }()
|
||||||
line = line[:ci]
|
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 err := scanner.Err(); err != nil {
|
||||||
if line == "" {
|
return nil, err
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if r := f.UnmarshalNATRule(line); r != nil {
|
case os.IsNotExist(err):
|
||||||
rules = append(rules, r)
|
// csf.redirect is optional; a missing file simply has no native rules.
|
||||||
}
|
default:
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// csf.redirect is CSF's own NAT config with no per-rule prefix marker, so no
|
// csf.redirect is CSF's own NAT config with no per-rule prefix marker, so no
|
||||||
// rule in it carries the configured prefix; HasPrefix stays false (mirroring
|
// rule in it carries the configured prefix; HasPrefix stays false (mirroring
|
||||||
// firewalld's zones).
|
// firewalld's zones). Hook NAT lines are raw iptables commands, so a rule this
|
||||||
return rules, nil
|
// library added there carries the prefix in its -m comment tag instead.
|
||||||
|
hookNAT, err := f.hook().getNATRules()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(rules, hookNAT...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirectAddr renders an address for a csf.redirect field, using "*" for an
|
// redirectAddr renders an address for a csf.redirect field, using "*" for an
|
||||||
|
|
@ -1491,7 +1594,8 @@ func (f *CSF) redirectPort(p uint16) string {
|
||||||
// ("IPx|portA|IPy|portB|proto"). csf.redirect expresses only destination NAT: a
|
// ("IPx|portA|IPy|portB|proto"). csf.redirect expresses only destination NAT: a
|
||||||
// Redirect to a local port (IPy = "*") or a DNAT forward to another host
|
// Redirect to a local port (IPy = "*") or a DNAT forward to another host
|
||||||
// (IPy = ToAddress). Source NAT, port ranges/lists, and non-tcp/udp protocols
|
// (IPy = ToAddress). Source NAT, port ranges/lists, and non-tcp/udp protocols
|
||||||
// are not representable.
|
// have no line here — AddNATRule routes those shapes through the pre-hook
|
||||||
|
// (natNeedsHook), so the rejections below guard only direct marshal callers.
|
||||||
func (f *CSF) MarshalNATRule(r *NATRule) (string, error) {
|
func (f *CSF) MarshalNATRule(r *NATRule) (string, error) {
|
||||||
if err := r.validate(); err != nil {
|
if err := r.validate(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -1603,8 +1707,24 @@ func (f *CSF) editRedirect(r *NATRule, remove bool) error {
|
||||||
return writeConfigFile(CSFRedirect, []byte(content), 0600)
|
return writeConfigFile(CSFRedirect, []byte(content), 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddNATRule adds a NAT rule to csf.redirect.
|
// AddNATRule adds a NAT rule to csf.redirect, or — for the shapes csf.redirect
|
||||||
|
// cannot hold (natNeedsHook) — as a raw nat-table command in the pre-hook.
|
||||||
func (f *CSF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
func (f *CSF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
||||||
|
if err := r.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A concrete-IPv6 translation cannot be kept in sync with IPV6 off: csf
|
||||||
|
// sources the pre-hook on every (re)load but only flushes the v6 nat table
|
||||||
|
// when IPV6 is on, so an injected ip6tables line would re-append each reload
|
||||||
|
// and outlive its own removal, and a csf.redirect v6 entry is never applied
|
||||||
|
// at all — the NAT analog of ipv6Unavailable.
|
||||||
|
if !f.ipv6Enabled && r.impliedFamily() == IPv6 {
|
||||||
|
return fmt.Errorf("csf cannot manage an IPv6 nat rule with IPV6 disabled: %w", ErrUnsupportedNAT)
|
||||||
|
}
|
||||||
|
if f.natNeedsHook(r) {
|
||||||
|
_, err := f.hook().editNAT(r, false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
return f.editRedirect(r, false)
|
return f.editRedirect(r, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1614,8 +1734,20 @@ func (f *CSF) InsertNATRule(ctx context.Context, zoneName string, position int,
|
||||||
return unsupportedOrdering(f.Type())
|
return unsupportedOrdering(f.Type())
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveNATRule removes a NAT rule from csf.redirect.
|
// RemoveNATRule removes a NAT rule from csf.redirect and from the pre-hook.
|
||||||
|
// The hook is swept first whatever the rule's shape: a hook-only shape lives
|
||||||
|
// nowhere else, and a natively-expressible rule may still have a stray hook
|
||||||
|
// copy a customer added by hand, mirroring the filter-side RemoveRule.
|
||||||
func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
||||||
|
if err := r.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := f.hook().editNAT(r, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f.natNeedsHook(r) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return f.editRedirect(r, true)
|
return f.editRedirect(r, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1659,6 +1791,7 @@ func (f *CSF) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a
|
// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a
|
||||||
|
// hook rule still references the set; removing an absent set is a no-op.
|
||||||
func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error {
|
func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error {
|
||||||
_, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
|
_, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -171,16 +171,16 @@ func TestCSFAdvRuleTCPUDPWithAddressFansOut(t *testing.T) {
|
||||||
// unenforceable line.
|
// unenforceable line.
|
||||||
func TestCSFAdvRuleAddressWithoutPortRouted(t *testing.T) {
|
func TestCSFAdvRuleAddressWithoutPortRouted(t *testing.T) {
|
||||||
fw := new(CSF)
|
fw := new(CSF)
|
||||||
require.True(t, hostNeedsHook(&Rule{Family: IPv4, Proto: TCP, Source: "192.0.2.10", Action: Drop}),
|
require.True(t, shapeNeedsHook(&Rule{Family: IPv4, Proto: TCP, Source: "192.0.2.10", Action: Drop}),
|
||||||
"a tcp host with no port must go to the hook, not an advanced line")
|
"a tcp host with no port must go to the hook, not an advanced line")
|
||||||
require.True(t, hostNeedsHook(&Rule{Family: IPv4, Proto: UDP, Destination: "192.0.2.10", Action: Accept}),
|
require.True(t, shapeNeedsHook(&Rule{Family: IPv4, Proto: UDP, Destination: "192.0.2.10", Action: Accept}),
|
||||||
"a udp host with no port must go to the hook, not an advanced line")
|
"a udp host with no port must go to the hook, not an advanced line")
|
||||||
|
|
||||||
// An ICMP rule with an address and no type takes its own route: csf's linefilter
|
// An ICMP rule with an address and no type takes its own route: csf's linefilter
|
||||||
// would consume the address as the icmp-type field, so it goes to the hook, whose
|
// would consume the address as the icmp-type field, so it goes to the hook, whose
|
||||||
// iptables rule needs no type.
|
// iptables rule needs no type.
|
||||||
icmp := &Rule{Family: IPv4, Proto: ICMP, Source: "192.0.2.10", Action: Accept}
|
icmp := &Rule{Family: IPv4, Proto: ICMP, Source: "192.0.2.10", Action: Accept}
|
||||||
require.False(t, hostNeedsHook(icmp), "icmp keeps its own handling")
|
require.False(t, shapeNeedsHook(icmp), "icmp keeps its own handling")
|
||||||
require.True(t, fw.needsHook(icmp), "a typeless icmp host must be routed to the hook")
|
require.True(t, fw.needsHook(icmp), "a typeless icmp host must be routed to the hook")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,12 +306,54 @@ func TestCSFSourcePorts(t *testing.T) {
|
||||||
// a destination port has no advanced-line form; neither does an address-less
|
// a destination port has no advanced-line form; neither does an address-less
|
||||||
// source port, since an advanced line requires an address. AddRule routes both to
|
// source port, since an advanced line requires an address. AddRule routes both to
|
||||||
// the raw-iptables hook rather than marshalling them.
|
// the raw-iptables hook rather than marshalling them.
|
||||||
require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}),
|
require.True(t, shapeNeedsHook(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}),
|
||||||
"a dual-port rule must be routed to the hook")
|
"a dual-port rule must be routed to the hook")
|
||||||
require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}),
|
require.True(t, shapeNeedsHook(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}),
|
||||||
"an address-less source-port rule must be routed to the hook")
|
"an address-less source-port rule must be routed to the hook")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCSFMultiPortRouting pins where a discrete multi-port list goes. An
|
||||||
|
// address-less multi-port accept has no single native form — a csf.conf port
|
||||||
|
// list stores each port as an independent token that reads back as its own rule
|
||||||
|
// — so it is injected through the hook's multiport match and round-trips whole;
|
||||||
|
// single-token shapes, addressed lists (an advanced line's port field is a
|
||||||
|
// comma list), and the port-only deny fan-out stay native.
|
||||||
|
func TestCSFMultiPortRouting(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
list := []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}
|
||||||
|
|
||||||
|
hooked := &Rule{Proto: TCP, Ports: list, Action: Accept}
|
||||||
|
require.True(t, fw.multiPortConfAccept(hooked), "an address-less multi-port accept is the conf-list overflow shape")
|
||||||
|
require.True(t, fw.needsHook(hooked), "an address-less multi-port accept must route to the hook")
|
||||||
|
lines, err := (&hookScript{}).rulesToLines(hooked)
|
||||||
|
require.NoError(t, err, "the hook must be able to render the multi-port rule")
|
||||||
|
require.Len(t, lines, 1)
|
||||||
|
require.Contains(t, lines[0], "-m multiport --dports 80,443")
|
||||||
|
|
||||||
|
native := []*Rule{
|
||||||
|
{Proto: TCP, Port: 80, Action: Accept}, // single port token
|
||||||
|
{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, // one range token
|
||||||
|
{Proto: TCP, Ports: list, Source: "1.2.3.4", Action: Accept}, // advanced line comma list
|
||||||
|
{Proto: TCP, Ports: list, Action: Drop}, // port-only deny placeholder rows
|
||||||
|
}
|
||||||
|
for _, r := range native {
|
||||||
|
require.False(t, fw.needsHook(r), "expected native routing for %+v", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCSFMultiPortRemovalSweepsConfTokens covers removal of a multi-port accept
|
||||||
|
// against a csf.conf port list: the rule lives in the hook now, but an earlier
|
||||||
|
// per-port add (or a manual edit) may hold the same ports as list tokens, so
|
||||||
|
// RemoveRule falls through to the port-list sweep, which strips exactly the
|
||||||
|
// target's tokens and no others.
|
||||||
|
func TestCSFMultiPortRemovalSweepsConfTokens(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
target := &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
|
||||||
|
require.Equal(t, `TCP_IN = "22"`,
|
||||||
|
fw.EditRulePort(`TCP_IN = "22,80,443"`, "TCP_IN", "22,80,443", target, true),
|
||||||
|
"a multi-port removal must strip each of its own port tokens and keep the rest")
|
||||||
|
}
|
||||||
|
|
||||||
// Only an ICMP rule with exactly one address and a concrete type is a csf advanced
|
// Only an ICMP rule with exactly one address and a concrete type is a csf advanced
|
||||||
// rule; every other ICMP shape is routed to the raw-iptables hook, which needs
|
// rule; every other ICMP shape is routed to the raw-iptables hook, which needs
|
||||||
// neither. csf's linefilter would otherwise consume the address as the icmp-type
|
// neither. csf's linefilter would otherwise consume the address as the icmp-type
|
||||||
|
|
@ -321,7 +363,6 @@ func TestCSFICMPRouting(t *testing.T) {
|
||||||
|
|
||||||
// With one address and a concrete type it is a valid advanced rule and round-trips.
|
// With one address and a concrete type it is a valid advanced rule and round-trips.
|
||||||
typed := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept}
|
typed := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept}
|
||||||
require.True(t, fw.nativeICMP(typed))
|
|
||||||
require.False(t, fw.needsHook(typed), "a typed icmp host is a native advanced rule")
|
require.False(t, fw.needsHook(typed), "a typed icmp host is a native advanced rule")
|
||||||
line := fw.MarshalAdvRule(typed)
|
line := fw.MarshalAdvRule(typed)
|
||||||
require.Equal(t, "icmp|in|d=8|s=1.2.3.4", line)
|
require.Equal(t, "icmp|in|d=8|s=1.2.3.4", line)
|
||||||
|
|
@ -339,7 +380,6 @@ func TestCSFICMPRouting(t *testing.T) {
|
||||||
}
|
}
|
||||||
h := &hookScript{}
|
h := &hookScript{}
|
||||||
for _, r := range hooked {
|
for _, r := range hooked {
|
||||||
require.False(t, fw.nativeICMP(r), "expected non-native icmp for %+v", *r)
|
|
||||||
require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r)
|
require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r)
|
||||||
lines, err := h.rulesToLines(r)
|
lines, err := h.rulesToLines(r)
|
||||||
require.NoError(t, err, "the hook must be able to render %+v", *r)
|
require.NoError(t, err, "the hook must be able to render %+v", *r)
|
||||||
|
|
@ -578,9 +618,9 @@ func TestCSFDropRuleRemovable(t *testing.T) {
|
||||||
// A ported csf advanced rule that matches both a source and a destination address
|
// A ported csf advanced rule that matches both a source and a destination address
|
||||||
// cannot be expressed (a csf advanced rule holds a single address field), so AddRule
|
// cannot be expressed (a csf advanced rule holds a single address field), so AddRule
|
||||||
// routes it to the raw-iptables hook rather than silently dropping the destination.
|
// routes it to the raw-iptables hook rather than silently dropping the destination.
|
||||||
// The portless bare form takes the same path one predicate earlier (hostNeedsHook).
|
// The portless bare form takes the same path (shapeNeedsHook routes both).
|
||||||
func TestCSFDualAddressRouted(t *testing.T) {
|
func TestCSFDualAddressRouted(t *testing.T) {
|
||||||
require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, Port: 80, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}),
|
require.True(t, shapeNeedsHook(&Rule{Proto: TCP, Port: 80, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}),
|
||||||
"a ported dual-address rule must be routed to the hook, never marshalled as an advanced line")
|
"a ported dual-address rule must be routed to the hook, never marshalled as an advanced line")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -672,17 +712,17 @@ func TestCSFDNATMarshalRejectsUnexpressible(t *testing.T) {
|
||||||
// (a bare protocol match) is injected through the pre-hook rather than rejected or
|
// (a bare protocol match) is injected through the pre-hook rather than rejected or
|
||||||
// silently written nowhere. csf keys every native rule on a port or an address, so
|
// silently written nowhere. csf keys every native rule on a port or an address, so
|
||||||
// such a rule maps to no csf construct; iptables expresses it directly, so addRule
|
// such a rule maps to no csf construct; iptables expresses it directly, so addRule
|
||||||
// diverts it to the hook (bareProtoNeedsHook). ICMP keeps its own handling.
|
// diverts it to the hook (shapeNeedsHook). ICMP keeps its own handling.
|
||||||
func TestCSFBareProtocolRoutesToHook(t *testing.T) {
|
func TestCSFBareProtocolRoutesToHook(t *testing.T) {
|
||||||
for _, r := range []*Rule{
|
for _, r := range []*Rule{
|
||||||
{Proto: TCP, Action: Accept},
|
{Proto: TCP, Action: Accept},
|
||||||
{Proto: ProtocolAny, Action: Accept},
|
{Proto: ProtocolAny, Action: Accept},
|
||||||
{Proto: UDP, Action: Drop},
|
{Proto: UDP, Action: Drop},
|
||||||
} {
|
} {
|
||||||
require.True(t, bareProtoNeedsHook(r),
|
require.True(t, shapeNeedsHook(r),
|
||||||
"a portless, addressless rule must route to the hook, not be rejected: %+v", r)
|
"a portless, addressless rule must route to the hook, not be rejected: %+v", r)
|
||||||
}
|
}
|
||||||
require.False(t, bareProtoNeedsHook(&Rule{Proto: ICMP, Action: Accept}),
|
require.False(t, shapeNeedsHook(&Rule{Proto: ICMP, Action: Accept}),
|
||||||
"an ICMP rule keeps its own handling and is excluded from the bare-protocol hook route")
|
"an ICMP rule keeps its own handling and is excluded from the bare-protocol hook route")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -739,7 +779,7 @@ func TestCSFPortOnlyRejectRoundTrip(t *testing.T) {
|
||||||
// A bare all-protocol host rule (address, no port) is the one portless address
|
// A bare all-protocol host rule (address, no port) is the one portless address
|
||||||
// shape csf.allow/csf.deny express, written as the plain address line. The
|
// shape csf.allow/csf.deny express, written as the plain address line. The
|
||||||
// inexpressible shapes — a concrete-protocol host or a source+destination pair —
|
// inexpressible shapes — a concrete-protocol host or a source+destination pair —
|
||||||
// are diverted to the hook by AddRule (hostNeedsHook) and never reach this
|
// are diverted to the hook by AddRule (shapeNeedsHook) and never reach this
|
||||||
// writer, so only the legitimate write is exercised here.
|
// writer, so only the legitimate write is exercised here.
|
||||||
func TestCSFBareHostWritten(t *testing.T) {
|
func TestCSFBareHostWritten(t *testing.T) {
|
||||||
fw := new(CSF)
|
fw := new(CSF)
|
||||||
|
|
@ -925,3 +965,110 @@ func TestCSFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
|
||||||
require.NotContains(t, string(data), "d=80",
|
require.NotContains(t, string(data), "d=80",
|
||||||
"removal must sweep the stale IPv6 line even with IPv6 off")
|
"removal must sweep the stale IPv6 line even with IPv6 off")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A protocol-less advanced line is enforced by csf.pl as `-p tcp` (its protocol
|
||||||
|
// default), so it must read back as TCP: a ProtocolAny read-back would report an
|
||||||
|
// all-protocol rule csf does not enforce, and one whose removal the iptables
|
||||||
|
// validity check rejects.
|
||||||
|
func TestCSFAdvRuleProtocolDefaultsTCP(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
r := fw.ParseAdvRule("in|d=80|s=192.0.2.1", Drop)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, TCP, r.Proto, "csf enforces a protocol-less advanced line as tcp")
|
||||||
|
require.EqualValues(t, 80, r.Port)
|
||||||
|
require.Equal(t, "192.0.2.1", r.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// csf.pl accepts a colon-delimited advanced line (converting `:` to `|` when the
|
||||||
|
// line has no pipe), so the parser must read it; an IPv6 literal keeps parsing as
|
||||||
|
// a plain address, never as a colon-delimited rule.
|
||||||
|
func TestCSFColonDelimitedAdvRule(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
r := fw.parseListLine("tcp:in:d=22:s=192.0.2.1", Drop)
|
||||||
|
require.NotNil(t, r, "a colon-delimited advanced line must parse")
|
||||||
|
require.Equal(t, TCP, r.Proto)
|
||||||
|
require.EqualValues(t, 22, r.Port)
|
||||||
|
require.Equal(t, "192.0.2.1", r.Source)
|
||||||
|
|
||||||
|
v6 := fw.parseListLine("2001:db8::7", Drop)
|
||||||
|
require.NotNil(t, v6)
|
||||||
|
require.Equal(t, "2001:db8::7", v6.Source, "an IPv6 literal is a plain address line")
|
||||||
|
require.Equal(t, DirAny, v6.Direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With csf.conf's IPV6 off, a family-agnostic port add must not touch the *6_*
|
||||||
|
// lists (their entries would be inert), a removal must still sweep them, and a
|
||||||
|
// concrete-IPv6 row written by Restore keeps its family.
|
||||||
|
func TestCSFEditRulePortIPv6Disabled(t *testing.T) {
|
||||||
|
off := &CSF{}
|
||||||
|
anyFam := &Rule{Proto: TCP, Port: 8080, Action: Accept}
|
||||||
|
|
||||||
|
got := off.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", anyFam, false)
|
||||||
|
require.Equal(t, `TCP6_IN = "22"`, got, "a family-agnostic add must not touch TCP6_IN while IPv6 is off")
|
||||||
|
got = off.EditRulePort(`TCP_IN = "22"`, "TCP_IN", "22", anyFam, false)
|
||||||
|
require.Contains(t, got, "8080", "the IPv4 list still takes the add")
|
||||||
|
|
||||||
|
// Removal sweeps the inert v6 entry so it does not outlive the rule.
|
||||||
|
got = off.EditRulePort(`TCP6_IN = "22,8080"`, "TCP6_IN", "22,8080", anyFam, true)
|
||||||
|
require.NotContains(t, got, "8080", "a removal must sweep the v6 list even with IPv6 off")
|
||||||
|
|
||||||
|
// A concrete-IPv6 rule (Restore reproducing a snapshot) keeps its family.
|
||||||
|
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Accept}
|
||||||
|
got = off.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", v6, false)
|
||||||
|
require.Contains(t, got, "8080", "a concrete-IPv6 write keeps its family")
|
||||||
|
|
||||||
|
on := &CSF{ipv6Enabled: true}
|
||||||
|
got = on.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", anyFam, false)
|
||||||
|
require.Contains(t, got, "8080", "with IPv6 on the v6 list takes the add")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRule of an iptables-inexpressible shape must reject with the sentinel
|
||||||
|
// before sweeping the hook, mirroring AddRule, so callers and the integration
|
||||||
|
// harness can tell "unsupported" from a real failure.
|
||||||
|
func TestCSFRemoveRuleUnsupportedShapeSentinel(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
err := fw.RemoveRule(context.Background(), "", &Rule{Proto: ProtocolAny, Port: 80, Action: Accept})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported, "an inexpressible removal must carry the sentinel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// natNeedsHook routes every shape csf.redirect cannot hold to the pre-hook:
|
||||||
|
// source NAT, non-tcp/udp, port sets, source/interface matches, an address-less
|
||||||
|
// family pin, and the DNAT pairings csf.pl aborts on. The two native shapes stay
|
||||||
|
// on csf.redirect.
|
||||||
|
func TestCSFNATNeedsHook(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
native := []*NATRule{
|
||||||
|
{Kind: Redirect, Proto: TCP, Port: 666, ToPort: 25},
|
||||||
|
{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1", ToPort: 25},
|
||||||
|
{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", ToAddress: "10.0.0.1"},
|
||||||
|
}
|
||||||
|
for _, r := range native {
|
||||||
|
require.False(t, fw.natNeedsHook(r), "csf.redirect holds %+v natively", *r)
|
||||||
|
}
|
||||||
|
hooked := []*NATRule{
|
||||||
|
{Kind: SNAT, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"},
|
||||||
|
{Kind: Masquerade, Interface: "eth1"},
|
||||||
|
{Kind: DNAT, Proto: ICMP, ToAddress: "10.0.0.1"},
|
||||||
|
{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "10.0.0.1"},
|
||||||
|
{Kind: Redirect, Proto: TCP, Port: 8080, Source: "192.0.2.0/24", ToPort: 80},
|
||||||
|
{Kind: DNAT, Proto: TCP, Interface: "eth0", Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1", ToPort: 25},
|
||||||
|
// A family pinned by nothing an address carries reads back agnostic from a
|
||||||
|
// csf.redirect line, so it must live in the hook to reconcile.
|
||||||
|
{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 8080, ToPort: 80},
|
||||||
|
// csf.pl aborts the redirect load on a half-set port pairing.
|
||||||
|
{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1"},
|
||||||
|
{Kind: DNAT, Proto: TCP, ToAddress: "10.0.0.1"},
|
||||||
|
}
|
||||||
|
for _, r := range hooked {
|
||||||
|
require.True(t, fw.natNeedsHook(r), "%+v has no csf.redirect form and must hook", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With IPV6 off a concrete-IPv6 NAT rule is rejected outright: csf neither
|
||||||
|
// applies a v6 redirect nor flushes the v6 nat table, so neither store could
|
||||||
|
// keep the rule in sync (the NAT analog of ipv6Unavailable, mirroring apf).
|
||||||
|
func TestCSFNATIPv6Gating(t *testing.T) {
|
||||||
|
off := new(CSF)
|
||||||
|
err := off.AddNATRule(context.Background(), "", &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 8080, ToAddress: "2001:db8::5"})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedNAT, "a concrete-IPv6 nat add must be rejected while IPV6 is off")
|
||||||
|
}
|
||||||
|
|
|
||||||
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
|
// Sentinel errors a caller can match with errors.Is to tell apart a genuine
|
||||||
// failure from a feature the active backend cannot express. The helpers below
|
// failure from a feature the active backend cannot express. The helpers below
|
||||||
// (rejectLogAndLimit, unsupportedNAT, ...) wrap these, and per-backend marshal
|
// (unsupportedNAT, unsupportedOrdering, ...) wrap these, and per-backend marshal
|
||||||
// paths use fmt.Errorf("...: %w", err) so the message stays readable while the
|
// paths use fmt.Errorf("...: %w", err) so the message stays readable while the
|
||||||
// sentinel is preserved for programmatic handling.
|
// sentinel is preserved for programmatic handling.
|
||||||
var (
|
var (
|
||||||
|
|
@ -112,7 +112,8 @@ func (t Family) String() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseFamily parses a family token (case-insensitive), accepting the canonical
|
// ParseFamily parses a family token (case-insensitive), accepting the canonical
|
||||||
// name emitted by Family.String. An unknown value is an error.
|
// name emitted by Family.String plus the common aliases (v4/inet4, v6/inet6).
|
||||||
|
// An unknown value is an error.
|
||||||
func ParseFamily(s string) (Family, error) {
|
func ParseFamily(s string) (Family, error) {
|
||||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
case "any":
|
case "any":
|
||||||
|
|
@ -238,10 +239,12 @@ var icmpNameToNum = map[string]uint8{
|
||||||
"traceroute": 30,
|
"traceroute": 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
// icmpv6NameToNum maps the ICMPv6 type names nftables and other tools print to
|
// icmpv6NameToNum maps the ICMPv6 type names nftables, ip6tables and ufw print
|
||||||
// their numeric type. ICMPv6 reuses several names from ICMPv4 (echo-request,
|
// to their numeric type (both the nftables nd-* spellings and the ip6tables
|
||||||
// destination-unreachable, ...) for *different* numbers, so a name read from an
|
// long forms such as router-solicitation). ICMPv6 reuses several names from
|
||||||
// ICMPv6 rule must be resolved through this table rather than icmpNameToNum.
|
// ICMPv4 (echo-request, destination-unreachable, ...) for *different* numbers,
|
||||||
|
// so a name read from an ICMPv6 rule must be resolved through this table rather
|
||||||
|
// than icmpNameToNum.
|
||||||
var icmpv6NameToNum = map[string]uint8{
|
var icmpv6NameToNum = map[string]uint8{
|
||||||
"destination-unreachable": 1,
|
"destination-unreachable": 1,
|
||||||
"packet-too-big": 2,
|
"packet-too-big": 2,
|
||||||
|
|
@ -257,10 +260,17 @@ var icmpv6NameToNum = map[string]uint8{
|
||||||
"mld-listener-done": 132,
|
"mld-listener-done": 132,
|
||||||
"mld-listener-reduction": 132,
|
"mld-listener-reduction": 132,
|
||||||
"nd-router-solicit": 133,
|
"nd-router-solicit": 133,
|
||||||
|
"router-solicitation": 133,
|
||||||
"nd-router-advert": 134,
|
"nd-router-advert": 134,
|
||||||
|
"router-advertisement": 134,
|
||||||
"nd-neighbor-solicit": 135,
|
"nd-neighbor-solicit": 135,
|
||||||
|
"neighbor-solicitation": 135,
|
||||||
|
"neighbour-solicitation": 135,
|
||||||
"nd-neighbor-advert": 136,
|
"nd-neighbor-advert": 136,
|
||||||
|
"neighbor-advertisement": 136,
|
||||||
|
"neighbour-advertisement": 136,
|
||||||
"nd-redirect": 137,
|
"nd-redirect": 137,
|
||||||
|
"redirect": 137,
|
||||||
"router-renumbering": 138,
|
"router-renumbering": 138,
|
||||||
"ind-neighbor-solicit": 141,
|
"ind-neighbor-solicit": 141,
|
||||||
"ind-neighbor-advert": 142,
|
"ind-neighbor-advert": 142,
|
||||||
|
|
@ -306,6 +316,9 @@ func ParseICMPType(tok string, v6 bool) (uint8, bool) {
|
||||||
|
|
||||||
// GetProtocol converts a string to the network protocol. The common spellings
|
// GetProtocol converts a string to the network protocol. The common spellings
|
||||||
// each backend emits for ICMPv6 (icmpv6, ipv6-icmp, icmp6) are all recognized.
|
// each backend emits for ICMPv6 (icmpv6, ipv6-icmp, icmp6) are all recognized.
|
||||||
|
// An unknown token resolves to ProtocolAny (the widest match), so a caller that
|
||||||
|
// must distinguish an unknown protocol from a genuine "any" checks the token
|
||||||
|
// itself, as the save-file parsers do.
|
||||||
func GetProtocol(proto string) Protocol {
|
func GetProtocol(proto string) Protocol {
|
||||||
switch {
|
switch {
|
||||||
case strings.EqualFold("udp", proto):
|
case strings.EqualFold("udp", proto):
|
||||||
|
|
@ -828,6 +841,10 @@ type Rule struct {
|
||||||
// backends. It is unexported and informational — it backs HasPrefix for the
|
// backends. It is unexported and informational — it backs HasPrefix for the
|
||||||
// container backends and, like HasPrefix, is not part of rule identity.
|
// container backends and, like HasPrefix, is not part of rule identity.
|
||||||
table string
|
table string
|
||||||
|
// meterSet records the dynamic set a per-source connection-limit row counts
|
||||||
|
// in, captured on read by the nftables backend so a removal can also clear
|
||||||
|
// the set. It is unexported, derived on read, and not part of rule identity.
|
||||||
|
meterSet string
|
||||||
}
|
}
|
||||||
|
|
||||||
// directionFromOutput maps an input/output boolean to a Direction. Backends whose
|
// directionFromOutput maps an input/output boolean to a Direction. Backends whose
|
||||||
|
|
@ -888,6 +905,12 @@ func (r *Rule) HasSourcePorts() bool {
|
||||||
return r.SourcePort != 0 || len(r.SourcePorts) > 0
|
return r.SourcePort != 0 || len(r.SourcePorts) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// perSourceLimited reports whether the rule carries a per-source connection
|
||||||
|
// limit, the form whose count is keyed on the source address.
|
||||||
|
func (r *Rule) perSourceLimited() bool {
|
||||||
|
return r.ConnLimit != nil && r.ConnLimit.PerSource
|
||||||
|
}
|
||||||
|
|
||||||
// HasPortSet reports whether the rule matches more than a single discrete port
|
// HasPortSet reports whether the rule matches more than a single discrete port
|
||||||
// (a list, or a range spanning more than one port). Backends limited to a single
|
// (a list, or a range spanning more than one port). Backends limited to a single
|
||||||
// port use this to reject rules they cannot represent.
|
// port use this to reject rules they cannot represent.
|
||||||
|
|
@ -919,8 +942,8 @@ func (r *Rule) HasSourcePortSet() bool {
|
||||||
// source port without a concrete port-carrying protocol (TCP/UDP/SCTP). Most
|
// source port without a concrete port-carrying protocol (TCP/UDP/SCTP). Most
|
||||||
// firewall backends cannot express a port match without such a protocol, so they
|
// firewall backends cannot express a port match without such a protocol, so they
|
||||||
// use this to reject such a rule rather than silently widening it (matching every
|
// use this to reject such a rule rather than silently widening it (matching every
|
||||||
// protocol) or emitting an invalid rule. UFW is the exception and can express a
|
// protocol) or emitting an invalid rule. A ported `any` in a ufw tuple is not this
|
||||||
// bare port across any protocol.
|
// shape: it means tcp+udp and is modeled as TCPUDP.
|
||||||
func (r *Rule) PortNeedsConcreteProtocol() bool {
|
func (r *Rule) PortNeedsConcreteProtocol() bool {
|
||||||
return portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) || portNeedsConcreteProtocol(r.SourcePort, r.SourcePorts, r.Proto)
|
return portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) || portNeedsConcreteProtocol(r.SourcePort, r.SourcePorts, r.Proto)
|
||||||
}
|
}
|
||||||
|
|
@ -1111,7 +1134,8 @@ func (r *Rule) matchFields(rule *Rule, directionHonored bool) bool {
|
||||||
// Equal reports whether two rules are the same. Family is compared through
|
// Equal reports whether two rules are the same. Family is compared through
|
||||||
// impliedFamily so a FamilyAny rule matches the concrete family its own content
|
// impliedFamily so a FamilyAny rule matches the concrete family its own content
|
||||||
// forces (an ICMP rule is IPv4, an ICMPv6 rule IPv6, an addressed rule its
|
// forces (an ICMP rule is IPv4, an ICMPv6 rule IPv6, an addressed rule its
|
||||||
// address's family).
|
// address's family). outputHonored is false on a backend with no output concept
|
||||||
|
// (Capabilities().Output), where direction does not distinguish two rules.
|
||||||
func (r *Rule) Equal(rule *Rule, outputHonored bool) bool {
|
func (r *Rule) Equal(rule *Rule, outputHonored bool) bool {
|
||||||
if r.impliedFamily() != rule.impliedFamily() {
|
if r.impliedFamily() != rule.impliedFamily() {
|
||||||
return false
|
return false
|
||||||
|
|
@ -1120,19 +1144,20 @@ func (r *Rule) Equal(rule *Rule, outputHonored bool) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// EqualBase reports whether two rules are the same, ignoring family.
|
// EqualBase reports whether two rules are the same, ignoring family.
|
||||||
|
// outputHonored has Equal's meaning.
|
||||||
func (r *Rule) EqualBase(rule *Rule, outputHonored bool) bool {
|
func (r *Rule) EqualBase(rule *Rule, outputHonored bool) bool {
|
||||||
return r.matchFields(rule, outputHonored)
|
return r.matchFields(rule, outputHonored)
|
||||||
}
|
}
|
||||||
|
|
||||||
// coversDirection reports whether an existing rule in direction have already
|
// coversDirection reports whether an existing rule's direction (have) already
|
||||||
// covers a caller rule in direction want (the asymmetric add/dedup form). DirAny
|
// covers a caller rule's direction (want) — the asymmetric add/dedup form. DirAny
|
||||||
// spans input and output, so it covers either; it never covers DirForward (a
|
// spans input and output, so it covers either; it never covers DirForward (a
|
||||||
// routed rule has no in/out twin). When outputHonored is false the backend has no
|
// routed rule has no in/out twin). When outputHonored is false the backend has no
|
||||||
// output concept, so direction never distinguishes two rules and coverage is
|
// output concept, so input and output never distinguish two rules — but a
|
||||||
// unconditional — behavior identical to the pre-DirAny code.
|
// forward rule is still its own chain and only covered by another forward rule.
|
||||||
func coversDirection(have, want Direction, outputHonored bool) bool {
|
func coversDirection(have, want Direction, outputHonored bool) bool {
|
||||||
if !outputHonored {
|
if !outputHonored {
|
||||||
return true
|
return (have == DirForward) == (want == DirForward)
|
||||||
}
|
}
|
||||||
if have == want {
|
if have == want {
|
||||||
return true
|
return true
|
||||||
|
|
@ -1145,7 +1170,7 @@ func coversDirection(have, want Direction, outputHonored bool) bool {
|
||||||
// it touches any concrete direction and vice versa. DirForward stands alone.
|
// it touches any concrete direction and vice versa. DirForward stands alone.
|
||||||
func coversDirectionRemoval(a, b Direction, outputHonored bool) bool {
|
func coversDirectionRemoval(a, b Direction, outputHonored bool) bool {
|
||||||
if !outputHonored {
|
if !outputHonored {
|
||||||
return true
|
return (a == DirForward) == (b == DirForward)
|
||||||
}
|
}
|
||||||
if a == b {
|
if a == b {
|
||||||
return true
|
return true
|
||||||
|
|
@ -1160,8 +1185,8 @@ func coversDirectionRemoval(a, b Direction, outputHonored bool) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// EqualForDedup reports whether the receiver (an existing rule) already makes o
|
// EqualForDedup reports whether the receiver (an existing rule) already makes o
|
||||||
// redundant on add: the same base rule, and the receiver's family and direction
|
// redundant on add: the same base rule, with the receiver's family, direction
|
||||||
// both cover o's. It is the family- and direction-aware add guard the container
|
// and transport (a TCPUDP row covers its tcp/udp halves) all covering o's. It is the family- and direction-aware add guard the container
|
||||||
// backends need because EqualBase ignores Family — without the coverage check,
|
// backends need because EqualBase ignores Family — without the coverage check,
|
||||||
// adding an IPv6 rule whose IPv4 twin already exists would be dropped as a false
|
// adding an IPv6 rule whose IPv4 twin already exists would be dropped as a false
|
||||||
// duplicate, leaving that family unprotected. Family and direction are checked
|
// duplicate, leaving that family unprotected. Family and direction are checked
|
||||||
|
|
@ -1175,7 +1200,7 @@ func (r *Rule) EqualForDedup(o *Rule, outputHonored bool) bool {
|
||||||
return r.covers(o, outputHonored)
|
return r.covers(o, outputHonored)
|
||||||
}
|
}
|
||||||
|
|
||||||
// coversFamily reports whether an existing rule's family have already covers a caller
|
// coversFamily reports whether an existing rule's family (have) already covers a caller
|
||||||
// rule's family want. FamilyAny spans both IP families, so it covers either; a
|
// rule's family want. FamilyAny spans both IP families, so it covers either; a
|
||||||
// concrete family covers only itself. Both sides are implied families, so an
|
// concrete family covers only itself. Both sides are implied families, so an
|
||||||
// address- or ICMP-pinned rule is compared by the family it actually targets.
|
// address- or ICMP-pinned rule is compared by the family it actually targets.
|
||||||
|
|
@ -1222,16 +1247,16 @@ func (r *Rule) Covers(o *Rule) bool {
|
||||||
// EqualForRemoval reports whether the receiver (an existing row) should be acted
|
// EqualForRemoval reports whether the receiver (an existing row) should be acted
|
||||||
// on when the caller targets o: the same base rule, and o's family, transport and
|
// on when the caller targets o: the same base rule, and o's family, transport and
|
||||||
// direction each touch the row's. It is the overlap relation a removal walks the
|
// direction each touch the row's. It is the overlap relation a removal walks the
|
||||||
// stored rows with, so a target removes every row it covers (rule 3) and also
|
// stored rows with: a target removes every row it covers, and also matches a row
|
||||||
// matches a row that covers more than the target, which the backend then deletes and
|
// that covers more than the target, which the backend then deletes and re-adds
|
||||||
// re-adds minus the targeted cell (splitMergedRow, rule 4). A FamilyAny/TCPUDP/DirAny
|
// minus the targeted cell (splitMergedRow). A FamilyAny/TCPUDP/DirAny target
|
||||||
// target matches every row on that axis, such a row matches any target, and otherwise
|
// matches every row on that axis, such a row matches any target, and otherwise
|
||||||
// the values must match so acting on one twin never disturbs the other. Family and
|
// the values must match so acting on one twin never disturbs the other. The
|
||||||
// direction are checked first so a non-matching row skips the field compare, which
|
// field compare runs in the inbound frame (canonicalMatch) with direction
|
||||||
// runs in the inbound frame (canonicalMatch) with direction excluded.
|
// excluded, since the axis gates above already decided it.
|
||||||
func (r *Rule) EqualForRemoval(o *Rule, outputHonored bool) bool {
|
func (r *Rule) EqualForRemoval(o *Rule, outputHonored bool) bool {
|
||||||
ft, fr := o.impliedFamily(), r.impliedFamily()
|
ft, fr := o.impliedFamily(), r.impliedFamily()
|
||||||
if !(ft == FamilyAny || fr == FamilyAny || ft == fr) {
|
if ft != FamilyAny && fr != FamilyAny && ft != fr {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if !coversDirectionRemoval(r.Direction, o.Direction, outputHonored) {
|
if !coversDirectionRemoval(r.Direction, o.Direction, outputHonored) {
|
||||||
|
|
@ -1280,6 +1305,21 @@ func splitDualRow(matched, target *Rule) *Rule {
|
||||||
return &opp
|
return &opp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitNATDualRow is splitDualRow's NAT analog: the copy of a genuine
|
||||||
|
// dual-family NAT row (a single stored translation with no family pin) that a
|
||||||
|
// backend re-adds after deleting it for a concrete-family removal, pinned to
|
||||||
|
// the family the caller did NOT target. It returns nil when no split applies.
|
||||||
|
// Family is the only axis a NAT rule spans.
|
||||||
|
func splitNATDualRow(matched, target *NATRule) *NATRule {
|
||||||
|
tf := target.impliedFamily()
|
||||||
|
if tf == FamilyAny || matched.impliedFamily() != FamilyAny {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
opp := *matched
|
||||||
|
opp.Family = oppositeFamily(tf)
|
||||||
|
return &opp
|
||||||
|
}
|
||||||
|
|
||||||
// expandDirections returns the concrete-direction rows a rule materializes into on
|
// expandDirections returns the concrete-direction rows a rule materializes into on
|
||||||
// write: itself for a concrete direction, or an inbound (DirInput) row plus its
|
// write: itself for a concrete direction, or an inbound (DirInput) row plus its
|
||||||
// role-swapped outbound (DirOutput) twin for a DirAny rule. Backends call it before
|
// role-swapped outbound (DirOutput) twin for a DirAny rule. Backends call it before
|
||||||
|
|
@ -1428,7 +1468,7 @@ func splitMergedRow(matched, target *Rule) []*Rule {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// coversProtocol reports whether an existing rule's protocol have already covers a
|
// coversProtocol reports whether an existing rule's protocol (have) already covers a
|
||||||
// caller rule's protocol want (the asymmetric add/dedup form). TCPUDP spans TCP and
|
// caller rule's protocol want (the asymmetric add/dedup form). TCPUDP spans TCP and
|
||||||
// UDP, so it covers either; every other protocol covers only itself. ProtocolAny is
|
// UDP, so it covers either; every other protocol covers only itself. ProtocolAny is
|
||||||
// not a merged value — it matches every IP protocol — so it covers only ProtocolAny.
|
// not a merged value — it matches every IP protocol — so it covers only ProtocolAny.
|
||||||
|
|
@ -1506,22 +1546,6 @@ func splitDualRowDirection(matched, target *Rule) *Rule {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rejectLogAndLimit reports a logging or rate/connection-limit request on a
|
|
||||||
// backend whose per-rule model cannot express it. Such a rule is rejected
|
|
||||||
// rather than applied without the modifier. It returns nil when the rule asks
|
|
||||||
// for none of these. backend names the backend for the error message.
|
|
||||||
func (r *Rule) rejectLogAndLimit(backend string) error {
|
|
||||||
switch {
|
|
||||||
case r.Log:
|
|
||||||
return fmt.Errorf("%s does not support per-rule logging in this model: %w", backend, ErrUnsupportedLog)
|
|
||||||
case r.RateLimit != nil:
|
|
||||||
return fmt.Errorf("%s does not support rate limiting in this model: %w", backend, ErrUnsupportedRateLimit)
|
|
||||||
case r.ConnLimit != nil:
|
|
||||||
return fmt.Errorf("%s does not support connection limiting in this model: %w", backend, ErrUnsupportedConnLimit)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// unsupportedNAT is the error a backend returns from its NAT methods when its
|
// unsupportedNAT is the error a backend returns from its NAT methods when its
|
||||||
// model cannot express network address translation. backend names the backend.
|
// model cannot express network address translation. backend names the backend.
|
||||||
func unsupportedNAT(backend string) error {
|
func unsupportedNAT(backend string) error {
|
||||||
|
|
@ -1661,8 +1685,10 @@ func (k NATKind) String() string {
|
||||||
return "invalid"
|
return "invalid"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseNATKind parses a NAT-kind token (case-insensitive), accepting the canonical
|
// ParseNATKind parses a NAT-kind token (case-insensitive), accepting only the
|
||||||
// name emitted by NATKind.String. An unknown value is an error.
|
// concrete kinds NATKind.String emits. The sentinel "invalid" (NATInvalid) is
|
||||||
|
// rejected, mirroring ParseAction, so callers cannot author a NAT rule with no
|
||||||
|
// real kind; backup decoding round-trips it separately in NATKind.UnmarshalJSON.
|
||||||
func ParseNATKind(s string) (NATKind, error) {
|
func ParseNATKind(s string) (NATKind, error) {
|
||||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
case "dnat":
|
case "dnat":
|
||||||
|
|
@ -1673,8 +1699,6 @@ func ParseNATKind(s string) (NATKind, error) {
|
||||||
return SNAT, nil
|
return SNAT, nil
|
||||||
case "masquerade":
|
case "masquerade":
|
||||||
return Masquerade, nil
|
return Masquerade, nil
|
||||||
case "invalid":
|
|
||||||
return NATInvalid, nil
|
|
||||||
}
|
}
|
||||||
return 0, fmt.Errorf("unknown nat kind %q", s)
|
return 0, fmt.Errorf("unknown nat kind %q", s)
|
||||||
}
|
}
|
||||||
|
|
@ -2065,6 +2089,11 @@ type Capabilities struct {
|
||||||
// PortList is true when multi-port (comma list) matching is honored. (Port
|
// PortList is true when multi-port (comma list) matching is honored. (Port
|
||||||
// ranges are supported by every backend, so they are not advertised.)
|
// ranges are supported by every backend, so they are not advertised.)
|
||||||
PortList bool
|
PortList bool
|
||||||
|
// PortPair is true when a source-port match can be combined with a
|
||||||
|
// destination-port match in one rule. A false PortPair means such a rule is
|
||||||
|
// rejected with ErrUnsupportedSourcePort (a firewalld rich rule carries a
|
||||||
|
// single port element).
|
||||||
|
PortPair bool
|
||||||
// ConnState is true when connection-tracking state can be matched.
|
// ConnState is true when connection-tracking state can be matched.
|
||||||
ConnState bool
|
ConnState bool
|
||||||
// InterfaceMatch is true when a rule can bind to a per-rule interface (as
|
// InterfaceMatch is true when a rule can bind to a per-rule interface (as
|
||||||
|
|
@ -2089,6 +2118,28 @@ type Capabilities struct {
|
||||||
// AddRule and populated by GetRules. A false Comments means the backend
|
// AddRule and populated by GetRules. A false Comments means the backend
|
||||||
// silently ignores the Comment field.
|
// silently ignores the Comment field.
|
||||||
Comments bool
|
Comments bool
|
||||||
|
// Negation is true when a "!"-negated Source/Destination match is honored.
|
||||||
|
// A false Negation means such a rule is rejected with ErrUnsupported (WFP
|
||||||
|
// has no negated address condition).
|
||||||
|
Negation bool
|
||||||
|
// RejectAction is true when the Reject action — refuse with an error
|
||||||
|
// response, as opposed to a silent Drop — is expressible. A false
|
||||||
|
// RejectAction means a Reject rule is rejected with ErrUnsupported (WFP
|
||||||
|
// only permits or blocks).
|
||||||
|
RejectAction bool
|
||||||
|
// FamilyWithoutAddress is true when a rule with a concrete Family but no
|
||||||
|
// Source/Destination is expressible. A false value means family scoping
|
||||||
|
// rides on addresses alone and such a rule is rejected with ErrUnsupported
|
||||||
|
// (a WFP filter scopes family through its address conditions).
|
||||||
|
FamilyWithoutAddress bool
|
||||||
|
// DenyActionFromConfig is true when the backend's native deny store carries
|
||||||
|
// no per-entry action — the firewall tool applies the deny action its own
|
||||||
|
// config names (csf.conf DROP, conf.apf ALL_STOP). A deny added with the
|
||||||
|
// config's action is stored natively and reads back with it; one with a
|
||||||
|
// differing action is expressed elsewhere (the raw-iptables hook). Because
|
||||||
|
// the native entry encodes no action, RemoveRule clears it whatever action
|
||||||
|
// the removal target names.
|
||||||
|
DenyActionFromConfig bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// The backend type strings Manager.Type reports, one per backend. They are declared
|
// The backend type strings Manager.Type reports, one per backend. They are declared
|
||||||
|
|
@ -2171,7 +2222,8 @@ type Manager interface {
|
||||||
// cannot manage a default policy return an unsupported error.
|
// cannot manage a default policy return an unsupported error.
|
||||||
SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error
|
SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error
|
||||||
|
|
||||||
// GetAddressSets returns the address sets managed by this backend.
|
// GetAddressSets returns the address sets managed by this backend. Backends
|
||||||
|
// that cannot manage address sets return an unsupported error.
|
||||||
GetAddressSets(ctx context.Context) ([]*AddressSet, error)
|
GetAddressSets(ctx context.Context) ([]*AddressSet, error)
|
||||||
|
|
||||||
// GetAddressSet returns a single address set by name, or an error if it does
|
// GetAddressSet returns a single address set by name, or an error if it does
|
||||||
|
|
@ -2326,10 +2378,22 @@ func Sync(ctx context.Context, mgr Manager, zoneName string, desired []*Rule) (a
|
||||||
}
|
}
|
||||||
toAdd = append(toAdd, d)
|
toAdd = append(toAdd, d)
|
||||||
}
|
}
|
||||||
if err := AddRules(ctx, mgr, zoneName, toAdd); err != nil {
|
// Count additions as they land so the reported added stays accurate on a
|
||||||
return added, removed, err
|
// 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
|
return added, removed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1689,3 +1689,45 @@ func TestNATCoveredBy(t *testing.T) {
|
||||||
require.True(t, dnatAny.CoveredBy([]*NATRule{dnatV4}),
|
require.True(t, dnatAny.CoveredBy([]*NATRule{dnatV4}),
|
||||||
"the translation address already pins this rule to IPv4")
|
"the translation address already pins this rule to IPv4")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// coversDirection with outputHonored=false collapses input/output but must not
|
||||||
|
// let an input row cover a DirForward target: forward is its own chain even on a
|
||||||
|
// backend with no output concept.
|
||||||
|
func TestCoversDirectionForwardIsolated(t *testing.T) {
|
||||||
|
require.True(t, coversDirection(DirInput, DirOutput, false))
|
||||||
|
require.True(t, coversDirection(DirOutput, DirInput, false))
|
||||||
|
require.False(t, coversDirection(DirInput, DirForward, false))
|
||||||
|
require.False(t, coversDirection(DirForward, DirInput, false))
|
||||||
|
require.True(t, coversDirection(DirForward, DirForward, false))
|
||||||
|
require.False(t, coversDirectionRemoval(DirInput, DirForward, false))
|
||||||
|
require.True(t, coversDirectionRemoval(DirForward, DirForward, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ip6tables/ufw ICMPv6 type spellings resolve alongside the nftables names.
|
||||||
|
func TestICMPv6NameAliases(t *testing.T) {
|
||||||
|
cases := map[string]uint8{
|
||||||
|
"router-solicitation": 133,
|
||||||
|
"router-advertisement": 134,
|
||||||
|
"neighbor-solicitation": 135,
|
||||||
|
"neighbour-solicitation": 135,
|
||||||
|
"neighbor-advertisement": 136,
|
||||||
|
"neighbour-advertisement": 136,
|
||||||
|
"redirect": 137,
|
||||||
|
"nd-redirect": 137,
|
||||||
|
}
|
||||||
|
for name, want := range cases {
|
||||||
|
n, ok := parseICMPTypeFamily(name, true)
|
||||||
|
require.True(t, ok, "name %q must resolve", name)
|
||||||
|
require.Equal(t, want, n, "name %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseNATKind rejects the "invalid" sentinel as caller input while the JSON
|
||||||
|
// round-trip still accepts it, mirroring Action.
|
||||||
|
func TestParseNATKindRejectsSentinel(t *testing.T) {
|
||||||
|
_, err := ParseNATKind("invalid")
|
||||||
|
require.Error(t, err)
|
||||||
|
var k NATKind
|
||||||
|
require.NoError(t, k.UnmarshalJSON([]byte(`"invalid"`)))
|
||||||
|
require.Equal(t, NATInvalid, k)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,16 @@ import (
|
||||||
firewalld "github.com/grmrgecko/go-firewalld"
|
firewalld "github.com/grmrgecko/go-firewalld"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager
|
||||||
|
// interface onto firewalld's zones, rich rules, and ipsets.
|
||||||
|
type FirewallD struct {
|
||||||
|
Conn *firewalld.Conn
|
||||||
|
// rulePrefix is accepted for a consistent constructor signature across
|
||||||
|
// backends. firewalld organizes rules into zones rather than a private
|
||||||
|
// namespace, so the prefix is not applied to individual rules.
|
||||||
|
rulePrefix string
|
||||||
|
}
|
||||||
|
|
||||||
// NewFirewallD connects to firewalld and returns a manager, or an error when
|
// NewFirewallD connects to firewalld and returns a manager, or an error when
|
||||||
// firewalld cannot be reached.
|
// firewalld cannot be reached.
|
||||||
func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) {
|
func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) {
|
||||||
|
|
@ -35,21 +45,27 @@ func (f *FirewallD) Type() string {
|
||||||
// Capabilities reports which optional features this backend supports.
|
// Capabilities reports which optional features this backend supports.
|
||||||
func (f *FirewallD) Capabilities() Capabilities {
|
func (f *FirewallD) Capabilities() Capabilities {
|
||||||
return Capabilities{
|
return Capabilities{
|
||||||
Output: false,
|
Output: false,
|
||||||
Zones: true,
|
Zones: true,
|
||||||
Priority: true,
|
Priority: true,
|
||||||
ICMPv6: true,
|
ICMPv6: true,
|
||||||
PortList: false,
|
PortList: false,
|
||||||
ConnState: false,
|
// A rich rule carries a single port element, so a source-port match
|
||||||
InterfaceMatch: false,
|
// cannot be combined with a destination-port match in one rule.
|
||||||
Logging: true,
|
PortPair: false,
|
||||||
RateLimit: true,
|
ConnState: false,
|
||||||
ConnLimit: false,
|
InterfaceMatch: false,
|
||||||
NAT: true,
|
Logging: true,
|
||||||
RuleOrdering: false,
|
RateLimit: true,
|
||||||
DefaultPolicy: true,
|
ConnLimit: false,
|
||||||
RuleCounters: false,
|
NAT: true,
|
||||||
AddressSets: 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.
|
// Ask firewalld directly which permanent zone the interface is bound to.
|
||||||
// This returns the zone id (e.g. "public"), which is what the other backend
|
// This returns the zone id (e.g. "public"), which is what the other backend
|
||||||
// methods expect to pass back into Permanent().Zone. An empty result or an
|
// methods expect to pass back into Permanent().Zone. An empty result or an
|
||||||
// error means the interface is not bound to a zone, so we fall through to
|
// error means the interface is not bound to a zone, leaving the default
|
||||||
// the default zone below.
|
// zone below.
|
||||||
zoneName, err = f.Conn.Permanent().ZoneOfInterface(ctx, iface)
|
zoneName, err = f.Conn.Permanent().ZoneOfInterface(ctx, iface)
|
||||||
if err == nil && zoneName != "" {
|
if err == nil && zoneName != "" {
|
||||||
return zoneName, nil
|
return zoneName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we did not find a zone for the specified interface, use the default
|
// An unbound interface belongs to the default zone.
|
||||||
// zone if it exists.
|
|
||||||
defaultZone, derr := f.Conn.DefaultZone(ctx)
|
defaultZone, derr := f.Conn.DefaultZone(ctx)
|
||||||
if derr == nil && defaultZone != "" {
|
if derr == nil && defaultZone != "" {
|
||||||
return defaultZone, nil
|
return defaultZone, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we were unable to find the zone or a default zone, error out.
|
|
||||||
return "", fmt.Errorf("unable to find zone")
|
return "", fmt.Errorf("unable to find zone")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,7 +239,10 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the destination, which firewalld expresses as either an address
|
// Parse the destination, which firewalld expresses as either an address
|
||||||
// or an ipset (mirroring the source grammar).
|
// or an ipset (mirroring the source grammar). firewalld has no output
|
||||||
|
// chain, so a destination element is an inbound-frame destination match,
|
||||||
|
// not a direction signal — the write side stores every rule in the
|
||||||
|
// inbound frame (see AddRule).
|
||||||
if strings.HasPrefix(tokens[i], "address=") {
|
if strings.HasPrefix(tokens[i], "address=") {
|
||||||
address := trimQuotes(strings.TrimPrefix(tokens[i], "address="))
|
address := trimQuotes(strings.TrimPrefix(tokens[i], "address="))
|
||||||
if not {
|
if not {
|
||||||
|
|
@ -233,7 +250,6 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
||||||
} else {
|
} else {
|
||||||
r.Destination = address
|
r.Destination = address
|
||||||
}
|
}
|
||||||
r.Direction = DirOutput
|
|
||||||
} else if strings.HasPrefix(tokens[i], "ipset=") {
|
} else if strings.HasPrefix(tokens[i], "ipset=") {
|
||||||
ipset := trimQuotes(strings.TrimPrefix(tokens[i], "ipset="))
|
ipset := trimQuotes(strings.TrimPrefix(tokens[i], "ipset="))
|
||||||
if not {
|
if not {
|
||||||
|
|
@ -241,7 +257,6 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
||||||
} else {
|
} else {
|
||||||
r.Destination = ipset
|
r.Destination = ipset
|
||||||
}
|
}
|
||||||
r.Direction = DirOutput
|
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("the destination argument has no address or ipset defined")
|
return nil, fmt.Errorf("the destination argument has no address or ipset defined")
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +272,13 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
// A limit element directly after log throttles only the logging, which
|
||||||
|
// RateLimit cannot hold — it models a traffic limit. Reading it as one
|
||||||
|
// would let a rewrite turn a log throttle into a traffic throttle, so
|
||||||
|
// the rule stays unmodeled.
|
||||||
|
if i+1 < len(tokens) && tokens[i+1] == "limit" {
|
||||||
|
return nil, fmt.Errorf("a log-scoped limit is unsupported")
|
||||||
|
}
|
||||||
} else if tokens[i] == "limit" {
|
} else if tokens[i] == "limit" {
|
||||||
// A rule-level rate limit: limit value="N/unit" where unit is one of
|
// A rule-level rate limit: limit value="N/unit" where unit is one of
|
||||||
// s/m/h/d. Parse it into the rule's RateLimit.
|
// s/m/h/d. Parse it into the rule's RateLimit.
|
||||||
|
|
@ -380,11 +402,17 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
||||||
// An icmp-type element restricts to a single ICMP message type by
|
// An icmp-type element restricts to a single ICMP message type by
|
||||||
// firewalld name, e.g. icmp-type name="echo-request". The numeric type
|
// firewalld name, e.g. icmp-type name="echo-request". The numeric type
|
||||||
// and the ICMP protocol both depend on the rule's family, which appears
|
// and the ICMP protocol both depend on the rule's family, which appears
|
||||||
// earlier in the rule string, so r.Family is already set here.
|
// earlier in the rule string, so r.Family is already set here. A
|
||||||
|
// familyless icmp-type rule (foreign; firewalld applies it to both
|
||||||
|
// families) has no single ICMP/ICMPv6 form, so it stays unmodeled
|
||||||
|
// rather than reading back as its IPv4 half.
|
||||||
i++
|
i++
|
||||||
if i >= len(tokens) || !strings.HasPrefix(tokens[i], "name=") {
|
if i >= len(tokens) || !strings.HasPrefix(tokens[i], "name=") {
|
||||||
return nil, fmt.Errorf("the icmp-type element has no defined name")
|
return nil, fmt.Errorf("the icmp-type element has no defined name")
|
||||||
}
|
}
|
||||||
|
if r.Family == FamilyAny {
|
||||||
|
return nil, fmt.Errorf("an icmp-type element without a family is unsupported")
|
||||||
|
}
|
||||||
isV6 := r.Family == IPv6
|
isV6 := r.Family == IPv6
|
||||||
num, ok := f.icmpTypeNumber(isV6, trimQuotes(strings.TrimPrefix(tokens[i], "name=")))
|
num, ok := f.icmpTypeNumber(isV6, trimQuotes(strings.TrimPrefix(tokens[i], "name=")))
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -649,7 +677,7 @@ func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) {
|
||||||
return "", fmt.Errorf("firewalld rich rules cannot match a destination and source port together: %w", ErrUnsupportedSourcePort)
|
return "", fmt.Errorf("firewalld rich rules cannot match a destination and source port together: %w", ErrUnsupportedSourcePort)
|
||||||
}
|
}
|
||||||
// An ICMP type only applies to an ICMP/ICMPv6 protocol; reject it paired with
|
// An ICMP type only applies to an ICMP/ICMPv6 protocol; reject it paired with
|
||||||
// anything else before we try to render an icmp-type element.
|
// anything else before rendering an icmp-type element.
|
||||||
if err := r.checkICMPType(); err != nil {
|
if err := r.checkICMPType(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -719,7 +747,7 @@ func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) {
|
||||||
// is no address to infer a family from — reject rather than emit a rule
|
// is no address to infer a family from — reject rather than emit a rule
|
||||||
// firewalld refuses with MISSING_FAMILY.
|
// firewalld refuses with MISSING_FAMILY.
|
||||||
if _, _, err := net.ParseCIDR(dst); err != nil && net.ParseIP(dst) == nil {
|
if _, _, err := net.ParseCIDR(dst); err != nil && net.ParseIP(dst) == nil {
|
||||||
return "", fmt.Errorf("firewalld requires an explicit Family for a destination ipset match")
|
return "", fmt.Errorf("firewalld requires an explicit Family for a destination ipset match: %w", ErrUnsupportedSet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -868,11 +896,48 @@ func (f *FirewallD) zoneEntryEligible(r *Rule) bool {
|
||||||
|
|
||||||
// AddRule adds a filter rule to a zone, using a zone-level entry when the rule
|
// AddRule adds a filter rule to a zone, using a zone-level entry when the rule
|
||||||
// fits one and a rich rule otherwise.
|
// fits one and a rich rule otherwise.
|
||||||
|
// resolveDestSetFamily pins a family-agnostic rule whose destination names an
|
||||||
|
// ipset to the set's own family: firewalld requires an explicit family for a
|
||||||
|
// destination ipset match (unlike a source one, which it accepts familyless),
|
||||||
|
// and an ipset is family-typed, so the rule could only ever match that family.
|
||||||
|
// It is the firewalld analog of the nft and hook layers' set-family resolution.
|
||||||
|
// Rules pinned already, or carrying an address that implies a family, pass
|
||||||
|
// through unchanged.
|
||||||
|
func (f *FirewallD) resolveDestSetFamily(ctx context.Context, r *Rule) (*Rule, error) {
|
||||||
|
if r.Family != FamilyAny || !isSetRef(r.Destination) {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
if familyOfAddr(r.Source) != FamilyAny {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
_, name := splitAddrNeg(strings.TrimSpace(r.Destination))
|
||||||
|
set, err := f.getAddressSet(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if set == nil {
|
||||||
|
return nil, fmt.Errorf("rule references unknown address set %q", name)
|
||||||
|
}
|
||||||
|
fam := set.Family
|
||||||
|
if fam != IPv6 {
|
||||||
|
fam = IPv4
|
||||||
|
}
|
||||||
|
rc := *r
|
||||||
|
rc.Family = fam
|
||||||
|
return &rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
// firewalld has no output chain (Capabilities().Output is false); it models a
|
// firewalld has no output chain (Capabilities().Output is false); it models a
|
||||||
// "destination" match rather than a true outbound direction. A both-directions
|
// "destination" match rather than a true outbound direction. A both-directions
|
||||||
// DirAny rule cannot be expressed, so it degrades to its input half.
|
// DirAny rule cannot be expressed, so it degrades to its input half, and a
|
||||||
|
// DirOutput rule is stored in the inbound frame (role-swapped) so both
|
||||||
|
// authorings of the same match converge on one stored form and read back
|
||||||
|
// identically — otherwise Sync would remove and re-add it forever.
|
||||||
r = dirAnyInputFallback(r, f.Capabilities().Output)
|
r = dirAnyInputFallback(r, f.Capabilities().Output)
|
||||||
|
if r.Direction == DirOutput {
|
||||||
|
r = r.canonicalMatch()
|
||||||
|
}
|
||||||
|
|
||||||
// A firewalld zone port and rich rule each carry a single protocol, so a
|
// A firewalld zone port and rich rule each carry a single protocol, so a
|
||||||
// TCPUDP rule has no single form; fan it into a tcp add and a udp add, each of
|
// TCPUDP rule has no single form; fan it into a tcp add and a udp add, each of
|
||||||
|
|
@ -891,8 +956,14 @@ func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error
|
||||||
return fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit)
|
return fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A family-agnostic destination set reference is pinned to the set's own family.
|
||||||
|
r, err := f.resolveDestSetFamily(ctx, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Get the zone.
|
// Get the zone.
|
||||||
zoneName, err := f.resolveZoneName(ctx, zoneName)
|
zoneName, err = f.resolveZoneName(ctx, zoneName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -970,18 +1041,27 @@ func (f *FirewallD) ignoreNotEnabled(err error) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager
|
// removeZoneEntry removes the zone-level entry — a zone port, source port, or
|
||||||
// interface onto firewalld's zones, rich rules, and ipsets.
|
// protocol — that a zone-entry-shaped rule maps to, reporting whether the rule's
|
||||||
type FirewallD struct {
|
// shape matched one of those forms. The firewalld error (including NOT_ENABLED)
|
||||||
Conn *firewalld.Conn
|
// is returned uninterpreted; RemoveRule's zone-entry shortcut and its dual-stack
|
||||||
// rulePrefix is accepted for a consistent constructor signature across
|
// split share this so their form routing cannot drift apart.
|
||||||
// backends. firewalld organizes rules into zones rather than a private
|
func (f *FirewallD) removeZoneEntry(ctx context.Context, zone *firewalld.PermZone, r *Rule) (bool, error) {
|
||||||
// namespace, so the prefix is not applied to individual rules.
|
switch {
|
||||||
rulePrefix string
|
case r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts():
|
||||||
|
return true, zone.RemovePort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()})
|
||||||
|
case r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "":
|
||||||
|
return true, zone.RemoveSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()})
|
||||||
|
case r.Proto != ProtocolAny && r.Source == "" && !r.HasPorts() && !r.HasSourcePorts():
|
||||||
|
return true, zone.RemoveProtocol(ctx, r.Proto.String())
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isZoneSource reports whether a source string is a form firewalld stores as a
|
// isZoneSource reports whether a source string is a form firewalld stores as a
|
||||||
// zone source: an IP, a CIDR, a MAC address, or an ipset reference (ipset:<name>).
|
// zone source: an IP, a CIDR, a MAC address, or an ipset reference (ipset:<name>).
|
||||||
|
// RemoveRule uses it to decide whether a bare source can be cleared with
|
||||||
|
// RemoveSource rather than falling through to the rich-rule path.
|
||||||
func (f *FirewallD) isZoneSource(s string) bool {
|
func (f *FirewallD) isZoneSource(s string) bool {
|
||||||
if _, _, err := net.ParseCIDR(s); err == nil {
|
if _, _, err := net.ParseCIDR(s); err == nil {
|
||||||
return true
|
return true
|
||||||
|
|
@ -998,12 +1078,17 @@ func (f *FirewallD) isZoneSource(s string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveRule uses it to decide whether a bare source can be cleared with
|
// RemoveRule removes a filter rule from a zone, clearing every form it may be
|
||||||
// RemoveSource rather than falling through to the rich-rule path.
|
// stored in: the zone-level entry AddRule would have used and any rich rule
|
||||||
|
// whose parsed form matches the target.
|
||||||
func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
// A DirAny rule degrades to its input half on firewalld (no output concept),
|
// A DirAny rule degrades to its input half on firewalld (no output concept),
|
||||||
// mirroring AddRule so a both-directions rule is found and removed as stored.
|
// and a DirOutput target is role-swapped into the inbound frame, mirroring
|
||||||
|
// AddRule so a rule is found and removed as stored.
|
||||||
r = dirAnyInputFallback(r, f.Capabilities().Output)
|
r = dirAnyInputFallback(r, f.Capabilities().Output)
|
||||||
|
if r.Direction == DirOutput {
|
||||||
|
r = r.canonicalMatch()
|
||||||
|
}
|
||||||
|
|
||||||
// firewalld stores a TCPUDP rule as two concrete rows (a tcp entry and a udp
|
// firewalld stores a TCPUDP rule as two concrete rows (a tcp entry and a udp
|
||||||
// entry), never one shared row, so such a removal fans into a tcp remove and a
|
// entry), never one shared row, so such a removal fans into a tcp remove and a
|
||||||
|
|
@ -1030,45 +1115,23 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
|
||||||
// so they are handled (ICMP) or rejected there rather than silently dropped by
|
// so they are handled (ICMP) or rejected there rather than silently dropped by
|
||||||
// the port/source shortcuts below. Logging and rate limiting also force the
|
// the port/source shortcuts below. Logging and rate limiting also force the
|
||||||
// rich-rule path. Each shortcut removes the element directly and relies on
|
// rich-rule path. Each shortcut removes the element directly and relies on
|
||||||
// firewalld's typed errors: a real error is returned, but NOT_ENABLED (the
|
// firewalld's typed errors: a real error is returned, while success and
|
||||||
// element is absent as a zone entry) falls through to the rich-rule path in
|
// NOT_ENABLED both continue to the rich-rule sweep — a rule present in both
|
||||||
// case the rule was stored in that form out of band.
|
// storage forms (a zone entry plus an equivalent rich rule added out of band)
|
||||||
|
// must be cleared from both in one call.
|
||||||
if f.zoneEntryEligible(r) {
|
if f.zoneEntryEligible(r) {
|
||||||
// A single destination port (or contiguous range) maps to a zone port entry.
|
if matched, rerr := f.removeZoneEntry(ctx, zone, r); matched {
|
||||||
// Mirror AddRule: key on HasPorts (so a single port in the Ports slice
|
if rerr != nil && !errors.Is(rerr, firewalld.ErrNotEnabled) {
|
||||||
// matches) and require a concrete port protocol.
|
return rerr
|
||||||
if r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts() {
|
|
||||||
port := firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()}
|
|
||||||
if err := zone.RemovePort(ctx, port); !errors.Is(err, firewalld.ErrNotEnabled) {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
} else if r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "" {
|
} else if f.sourceZoneShape(r) && f.isZoneSource(r.Source) {
|
||||||
// A single source port maps to a zone source-port entry.
|
|
||||||
sp := firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()}
|
|
||||||
if err := zone.RemoveSourcePort(ctx, sp); !errors.Is(err, firewalld.ErrNotEnabled) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if f.sourceZoneShape(r) {
|
|
||||||
// A bare source maps to a zone source. firewalld stores an IP/CIDR, a MAC
|
// A bare source maps to a zone source. firewalld stores an IP/CIDR, a MAC
|
||||||
// address, or an ipset reference (ipset:<name>) as a zone source, and
|
// address, or an ipset reference (ipset:<name>) as a zone source, and
|
||||||
// GetRules reports each verbatim, so removal must try RemoveSource for all
|
// GetRules reports each verbatim, so removal must try RemoveSource for all
|
||||||
// of them. A value that is not a zone source form falls through to the
|
// of them. A value that is not a zone source form is left to the rich-rule
|
||||||
// rich-rule path below.
|
// path below; sourceZoneShape mirrors AddRule's ProtocolAny guard, since a
|
||||||
// sourceZoneShape mirrors AddRule's ProtocolAny guard: a source+protocol
|
// source+protocol rule is stored as a rich rule.
|
||||||
// rule is a rich rule, so it must be removed as one rather than clearing
|
if err := zone.RemoveSource(ctx, r.Source); err != nil && !errors.Is(err, firewalld.ErrNotEnabled) {
|
||||||
// the bare zone source.
|
|
||||||
if f.isZoneSource(r.Source) {
|
|
||||||
if err := zone.RemoveSource(ctx, r.Source); !errors.Is(err, firewalld.ErrNotEnabled) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if r.Proto != ProtocolAny && r.Source == "" && !r.HasPorts() && !r.HasSourcePorts() {
|
|
||||||
// A bare protocol allow (no port or address) maps to a zone protocol
|
|
||||||
// entry (firewall-cmd --add-protocol), which GetRules now surfaces.
|
|
||||||
// Mirror the port/source shortcuts so such a foreign entry is removable;
|
|
||||||
// a NOT_ENABLED result falls through to the rich-rule path for a rule the
|
|
||||||
// library stored as a rich-rule protocol match instead.
|
|
||||||
if err := zone.RemoveProtocol(ctx, r.Proto.String()); !errors.Is(err, firewalld.ErrNotEnabled) {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1088,19 +1151,7 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
|
||||||
rAny := *r
|
rAny := *r
|
||||||
rAny.Family = FamilyAny
|
rAny.Family = FamilyAny
|
||||||
if f.zoneEntryEligible(&rAny) && r.Source == "" {
|
if f.zoneEntryEligible(&rAny) && r.Source == "" {
|
||||||
var removeErr error
|
if matched, removeErr := f.removeZoneEntry(ctx, zone, r); matched {
|
||||||
matched := true
|
|
||||||
switch {
|
|
||||||
case r.HasPorts() && r.Proto.HasPorts() && !r.HasSourcePorts():
|
|
||||||
removeErr = zone.RemovePort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()})
|
|
||||||
case r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts():
|
|
||||||
removeErr = zone.RemoveSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()})
|
|
||||||
case r.Proto != ProtocolAny && !r.HasPorts() && !r.HasSourcePorts():
|
|
||||||
removeErr = zone.RemoveProtocol(ctx, r.Proto.String())
|
|
||||||
default:
|
|
||||||
matched = false
|
|
||||||
}
|
|
||||||
if matched {
|
|
||||||
if removeErr == nil {
|
if removeErr == nil {
|
||||||
if s := splitDualRow(&rAny, r); s != nil {
|
if s := splitDualRow(&rAny, r); s != nil {
|
||||||
return f.AddRule(ctx, zoneName, s)
|
return f.AddRule(ctx, zoneName, s)
|
||||||
|
|
@ -1128,7 +1179,7 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
|
||||||
if r.Proto == ICMPv6 {
|
if r.Proto == ICMPv6 {
|
||||||
proto = "ipv6-icmp"
|
proto = "ipv6-icmp"
|
||||||
}
|
}
|
||||||
if err := zone.RemoveProtocol(ctx, proto); !errors.Is(err, firewalld.ErrNotEnabled) {
|
if err := zone.RemoveProtocol(ctx, proto); err != nil && !errors.Is(err, firewalld.ErrNotEnabled) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1187,7 +1238,7 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the zone settings so we can read forward ports and masquerade.
|
// Read the zone settings for forward ports and masquerade.
|
||||||
settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx)
|
settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -1195,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
|
// Each forward port maps to a DNAT (when it targets another address) or a
|
||||||
// Redirect (same host, port only). firewalld's model carries no family, so
|
// Redirect (same host, port only). firewalld's model carries no family, so
|
||||||
// these are returned as FamilyAny.
|
// these are returned as FamilyAny. An entry the model cannot hold stays
|
||||||
|
// unmanaged (see modeledForwardPort).
|
||||||
for _, fp := range settings.ForwardPorts {
|
for _, fp := range settings.ForwardPorts {
|
||||||
pr, perr := ParsePortRange(fp.Port)
|
if rule := f.modeledForwardPort(fp); rule != nil {
|
||||||
if perr != nil {
|
rules = append(rules, rule)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
rule := &NATRule{Proto: GetProtocol(fp.Protocol)}
|
|
||||||
if pr.Start == pr.End {
|
|
||||||
rule.Port = pr.Start
|
|
||||||
} else {
|
|
||||||
rule.Ports = []PortRange{pr}
|
|
||||||
}
|
|
||||||
if fp.ToPort != "" {
|
|
||||||
tp, terr := strconv.ParseUint(fp.ToPort, 10, 16)
|
|
||||||
if terr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rule.ToPort = uint16(tp)
|
|
||||||
}
|
|
||||||
rule.ToAddress = fp.ToAddr
|
|
||||||
if fp.ToAddr != "" {
|
|
||||||
rule.Kind = DNAT
|
|
||||||
} else {
|
|
||||||
rule.Kind = Redirect
|
|
||||||
}
|
|
||||||
rules = append(rules, rule)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zone masquerade maps to a single Masquerade 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
|
return rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modeledForwardPort decodes a zone forward-port entry into the NATRule
|
||||||
|
// GetNATRules reports, or nil for an entry the model cannot hold: an
|
||||||
|
// unparseable port, a protocol the write side rejects (sctp/dccp — forwardPort
|
||||||
|
// takes tcp/udp only, so such a rule could never be removed or re-added), or a
|
||||||
|
// target-port range. An unmodeled entry stays unmanaged, and Restore leaves it
|
||||||
|
// in place for the same reason.
|
||||||
|
func (f *FirewallD) modeledForwardPort(fp firewalld.ForwardPort) *NATRule {
|
||||||
|
pr, perr := ParsePortRange(fp.Port)
|
||||||
|
if perr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proto := GetProtocol(fp.Protocol)
|
||||||
|
if proto != TCP && proto != UDP {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rule := &NATRule{Proto: proto}
|
||||||
|
if pr.Start == pr.End {
|
||||||
|
rule.Port = pr.Start
|
||||||
|
} else {
|
||||||
|
rule.Ports = []PortRange{pr}
|
||||||
|
}
|
||||||
|
if fp.ToPort != "" {
|
||||||
|
tp, terr := strconv.ParseUint(fp.ToPort, 10, 16)
|
||||||
|
if terr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rule.ToPort = uint16(tp)
|
||||||
|
}
|
||||||
|
rule.ToAddress = fp.ToAddr
|
||||||
|
if fp.ToAddr != "" {
|
||||||
|
rule.Kind = DNAT
|
||||||
|
} else {
|
||||||
|
rule.Kind = Redirect
|
||||||
|
}
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's
|
// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's
|
||||||
// per-zone port-forward API expects (port, protocol, toport, toaddr). That API
|
// per-zone port-forward API expects (port, protocol, toport, toaddr). That API
|
||||||
// carries only these four fields, so any source, destination or interface match —
|
// carries only these four fields, so any source, destination or interface match —
|
||||||
|
|
@ -1246,20 +1314,20 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
|
||||||
// would not round-trip, so it is intentionally not emitted here.)
|
// would not round-trip, so it is intentionally not emitted here.)
|
||||||
func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) {
|
func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) {
|
||||||
if r.Proto != TCP && r.Proto != UDP {
|
if r.Proto != TCP && r.Proto != UDP {
|
||||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol")
|
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol: %w", ErrUnsupportedNAT)
|
||||||
}
|
}
|
||||||
if !r.HasPorts() {
|
if !r.HasPorts() {
|
||||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port")
|
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port: %w", ErrUnsupportedNAT)
|
||||||
}
|
}
|
||||||
specs := r.PortSpecs()
|
specs := r.PortSpecs()
|
||||||
if len(specs) > 1 {
|
if len(specs) > 1 {
|
||||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward")
|
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward: %w", ErrUnsupportedNAT)
|
||||||
}
|
}
|
||||||
if r.Interface != "" {
|
if r.Interface != "" {
|
||||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface")
|
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface: %w", ErrUnsupportedNAT)
|
||||||
}
|
}
|
||||||
if r.Source != "" || r.Destination != "" {
|
if r.Source != "" || r.Destination != "" {
|
||||||
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching")
|
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching: %w", ErrUnsupportedNAT)
|
||||||
}
|
}
|
||||||
fp := firewalld.ForwardPort{
|
fp := firewalld.ForwardPort{
|
||||||
Port: specs[0].String(),
|
Port: specs[0].String(),
|
||||||
|
|
@ -1401,7 +1469,10 @@ func (f *FirewallD) SetDefaultPolicy(ctx context.Context, zoneName string, polic
|
||||||
|
|
||||||
// --- address sets (firewalld ipsets) ----------------------------------------
|
// --- address sets (firewalld ipsets) ----------------------------------------
|
||||||
|
|
||||||
// getAddressSet reads a single firewalld ipset, or nil if it does not exist.
|
// getAddressSet reads a single firewalld ipset, or nil if it does not exist or
|
||||||
|
// has a type the model cannot hold. Only hash:ip and hash:net map onto SetType;
|
||||||
|
// coercing another type (hash:mac, hash:ip,port, ...) would let a Backup/Restore
|
||||||
|
// rewrite the set as a bare hash:ip and break it, so those stay unmanaged.
|
||||||
func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
||||||
settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx)
|
settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx)
|
||||||
if errors.Is(err, firewalld.ErrInvalidIPSet) {
|
if errors.Is(err, firewalld.ErrInvalidIPSet) {
|
||||||
|
|
@ -1410,9 +1481,14 @@ func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
set := &AddressSet{Name: name, Entries: settings.Entries, Type: SetHashIP}
|
set := &AddressSet{Name: name, Entries: settings.Entries}
|
||||||
if settings.Type == "hash:net" {
|
switch settings.Type {
|
||||||
|
case "hash:ip":
|
||||||
|
set.Type = SetHashIP
|
||||||
|
case "hash:net":
|
||||||
set.Type = SetHashNet
|
set.Type = SetHashNet
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
switch settings.Options["family"] {
|
switch settings.Options["family"] {
|
||||||
case "inet6":
|
case "inet6":
|
||||||
|
|
@ -1568,13 +1644,22 @@ func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove all ports, sources, and rich rules that we can decode as managed.
|
// Remove the zone entries and rich rules the model manages. Each removal
|
||||||
|
// mirrors the guard its read side applies, so an entry GetRules skips as
|
||||||
|
// unmodeled (a dccp port, an unrecognized protocol, an unparseable rich rule)
|
||||||
|
// is preserved rather than destroyed by a Backup that never captured it.
|
||||||
for _, port := range settings.Ports {
|
for _, port := range settings.Ports {
|
||||||
|
if len(f.zonePortRules([]firewalld.Port{port}, false)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := zone.RemovePort(ctx, port); err != nil {
|
if err := zone.RemovePort(ctx, port); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, sp := range settings.SourcePorts {
|
for _, sp := range settings.SourcePorts {
|
||||||
|
if len(f.zonePortRules([]firewalld.Port{sp}, true)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := zone.RemoveSourcePort(ctx, sp); err != nil {
|
if err := zone.RemoveSourcePort(ctx, sp); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1585,18 +1670,28 @@ func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, proto := range settings.Protocols {
|
for _, proto := range settings.Protocols {
|
||||||
|
if GetProtocol(proto) == ProtocolAny {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := zone.RemoveProtocol(ctx, proto); err != nil {
|
if err := zone.RemoveProtocol(ctx, proto); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, richRule := range settings.RichRules {
|
for _, richRule := range settings.RichRules {
|
||||||
|
if _, perr := f.UnmarshalRichRule(richRule); perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := zone.RemoveRichRule(ctx, richRule); err != nil {
|
if err := zone.RemoveRichRule(ctx, richRule); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove NAT rules.
|
// Remove the modeled NAT entries, preserving forward ports the model cannot
|
||||||
|
// hold for the same reason.
|
||||||
for _, fp := range settings.ForwardPorts {
|
for _, fp := range settings.ForwardPorts {
|
||||||
|
if f.modeledForwardPort(fp) == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := zone.RemoveForwardPort(ctx, fp); err != nil {
|
if err := zone.RemoveForwardPort(ctx, fp); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,17 +35,24 @@ func TestFirewallDZonePortRulesSkipsUnmodeledProto(t *testing.T) {
|
||||||
func TestFirewallDRichRules(t *testing.T) {
|
func TestFirewallDRichRules(t *testing.T) {
|
||||||
fw := new(FirewallD)
|
fw := new(FirewallD)
|
||||||
|
|
||||||
// Parse a rule that is expected to parse right.
|
// Parse a rule that is expected to parse right: an action-scoped limit
|
||||||
rule, err := fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log limit value="1/m" audit accept`)
|
// (after accept) is the traffic limit RateLimit models.
|
||||||
|
rule, err := fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log audit accept limit value="1/m"`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Re-encode the rule which should result in expected rich rule. The log and
|
// Re-encode the rule which should result in expected rich rule. The log and
|
||||||
// rate limit now round-trip (audit is still not modeled and is dropped).
|
// rate limit round-trip (audit is still not modeled and is dropped).
|
||||||
richRule, err := fw.MarshalRichRule(rule)
|
richRule, err := fw.MarshalRichRule(rule)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, `rule family="ipv4" source address="192.168.0.0/24" port port="23" protocol="udp" log level="info" accept limit value="1/m"`, richRule,
|
require.Equal(t, `rule family="ipv4" source address="192.168.0.0/24" port port="23" protocol="udp" log level="info" accept limit value="1/m"`, richRule,
|
||||||
"the rich rule did not encode as expected")
|
"the rich rule did not encode as expected")
|
||||||
|
|
||||||
|
// A log-scoped limit (directly after log) throttles only the logging, which
|
||||||
|
// the model cannot hold; reading it as a traffic limit would let a rewrite
|
||||||
|
// change what the rule enforces, so it must stay unmodeled.
|
||||||
|
_, err = fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log limit value="1/m" audit accept`)
|
||||||
|
require.Error(t, err, "a log-scoped limit must be rejected")
|
||||||
|
|
||||||
// Try encoding a bunch of invalid rules.
|
// Try encoding a bunch of invalid rules.
|
||||||
invalidRules := []string{
|
invalidRules := []string{
|
||||||
`rule family=ipv4 source address="192.168.0.0/24" service name=ftp reject`,
|
`rule family=ipv4 source address="192.168.0.0/24" service name=ftp reject`,
|
||||||
|
|
@ -194,8 +201,7 @@ func TestFirewallDICMPType(t *testing.T) {
|
||||||
// matching a destination ipset must marshal to `destination ipset="..."` and
|
// matching a destination ipset must marshal to `destination ipset="..."` and
|
||||||
// round-trip, rather than being rejected or read back as invisible. Unlike a
|
// round-trip, rather than being rejected or read back as invisible. Unlike a
|
||||||
// source ipset, firewalld rejects a familyless destination ipset with
|
// source ipset, firewalld rejects a familyless destination ipset with
|
||||||
// MISSING_FAMILY (verified against the real firewalld.core.rich parser), so the
|
// MISSING_FAMILY, so the caller must supply a concrete Family.
|
||||||
// caller must supply a concrete Family.
|
|
||||||
func TestFirewallDDestinationIPSet(t *testing.T) {
|
func TestFirewallDDestinationIPSet(t *testing.T) {
|
||||||
fw := new(FirewallD)
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
|
@ -207,7 +213,8 @@ func TestFirewallDDestinationIPSet(t *testing.T) {
|
||||||
parsed, err := fw.UnmarshalRichRule(got)
|
parsed, err := fw.UnmarshalRichRule(got)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "myset", parsed.Destination)
|
require.Equal(t, "myset", parsed.Destination)
|
||||||
require.True(t, parsed.IsOutput(), "a destination match is an output rule")
|
require.False(t, parsed.IsOutput(),
|
||||||
|
"a destination element is an inbound-frame match, not a direction signal")
|
||||||
|
|
||||||
// A negated destination ipset round-trips too.
|
// A negated destination ipset round-trips too.
|
||||||
neg := &Rule{Family: IPv4, Destination: "!badset", Proto: TCP, Port: 443, Action: Drop}
|
neg := &Rule{Family: IPv4, Destination: "!badset", Proto: TCP, Port: 443, Action: Drop}
|
||||||
|
|
@ -460,12 +467,10 @@ func TestFirewalldZoneEntryEligibleRange(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFirewallDBareICMPFamilyPinned guards that a bare (untyped) ICMP/ICMPv6 rule
|
// TestFirewallDBareICMPFamilyPinned guards that a bare (untyped) ICMP/ICMPv6 rule
|
||||||
// is left unqualified by family and still round-trips correctly. Live testing
|
// is left unqualified by family and still round-trips correctly. firewalld only
|
||||||
// against firewalld 2.4.3 confirmed it accepts a familyless rich rule carrying
|
// requires a family for a source/destination address, never for a bare protocol
|
||||||
// `protocol value="ipv6-icmp"` just like any other protocol (its rich.py check()
|
// match, and the protocol value string alone ("icmp" vs "ipv6-icmp") tells the
|
||||||
// only requires a family for a source/destination address, never for a bare
|
// read path ICMP from ICMPv6.
|
||||||
// protocol match), and the protocol value string alone ("icmp" vs "ipv6-icmp")
|
|
||||||
// tells the read path ICMP from ICMPv6 without needing a family qualifier.
|
|
||||||
func TestFirewallDBareICMPFamilyPinned(t *testing.T) {
|
func TestFirewallDBareICMPFamilyPinned(t *testing.T) {
|
||||||
fw := new(FirewallD)
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
|
@ -595,3 +600,26 @@ func TestFirewalldICMPv6DestinationUnreachable(t *testing.T) {
|
||||||
require.NoError(t, err, "an icmpv6 destination-unreachable rich rule must parse")
|
require.NoError(t, err, "an icmpv6 destination-unreachable rich rule must parse")
|
||||||
require.True(t, r.Equal(back, false), "icmpv6 destination-unreachable must round-trip: got %+v", back)
|
require.True(t, r.Equal(back, false), "icmpv6 destination-unreachable must round-trip: got %+v", back)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modeledForwardPort must reject the forward-port shapes the write side
|
||||||
|
// (forwardPort) cannot re-create — otherwise GetNATRules would surface entries
|
||||||
|
// that can never be removed or restored, and Restore would destroy them.
|
||||||
|
func TestFirewallDModeledForwardPort(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
dnat := fw.modeledForwardPort(firewalld.ForwardPort{Port: "8080", Protocol: "tcp", ToPort: "80", ToAddr: "10.0.0.5"})
|
||||||
|
require.NotNil(t, dnat)
|
||||||
|
require.Equal(t, DNAT, dnat.Kind)
|
||||||
|
require.EqualValues(t, 8080, dnat.Port)
|
||||||
|
require.EqualValues(t, 80, dnat.ToPort)
|
||||||
|
|
||||||
|
redirect := fw.modeledForwardPort(firewalld.ForwardPort{Port: "8443", Protocol: "udp", ToPort: "443"})
|
||||||
|
require.NotNil(t, redirect)
|
||||||
|
require.Equal(t, Redirect, redirect.Kind)
|
||||||
|
|
||||||
|
// Shapes forwardPort rejects stay unmodeled: a non-tcp/udp protocol and a
|
||||||
|
// target-port range.
|
||||||
|
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "9999", Protocol: "sctp", ToPort: "80", ToAddr: "10.0.0.5"}))
|
||||||
|
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "9999", Protocol: "dccp"}))
|
||||||
|
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "8080", Protocol: "tcp", ToPort: "8080-8090", ToAddr: "10.0.0.5"}))
|
||||||
|
}
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -2,11 +2,9 @@ module git.gec.im/GRMrGecko/go-firewall
|
||||||
|
|
||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
|
||||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
github.com/coreos/go-systemd/v22 v22.5.0
|
||||||
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb
|
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb
|
||||||
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847
|
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
|
|
||||||
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,
|
// through the hook: a forward-chain (routed) rule, connection-state matching,
|
||||||
// per-rule interface matching, logging, rate limiting, ICMPv6, a transport
|
// per-rule interface matching, logging, rate limiting, ICMPv6, a transport
|
||||||
// protocol their native config does not model (SCTP and the portless IP protocols
|
// protocol their native config does not model (SCTP and the portless IP protocols
|
||||||
// GRE, ESP and AH), or an address-set reference (@set) — csf.allow/apf trust files
|
// GRE, ESP and AH), an address-set reference (@set), or a negated address.
|
||||||
// take literal addresses only, so a `-m set --match-set` match lives in the hook
|
// csf.allow/apf trust files take literal addresses only, so a `-m set
|
||||||
// beside the ipset commands that create the set.
|
// --match-set` match lives in the hook beside the ipset commands that create the
|
||||||
|
// set, and a negated address lives there as iptables' native `! -s`/`! -d` —
|
||||||
|
// csf.pl passes an advanced line's s=/d= value verbatim to iptables (where a
|
||||||
|
// joined "!" is not a reliable negation) and skips a plain "!"-prefixed line as
|
||||||
|
// not an address, so neither native form can carry one.
|
||||||
func ruleNeedsHook(r *Rule) bool {
|
func ruleNeedsHook(r *Rule) bool {
|
||||||
return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" ||
|
return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" ||
|
||||||
r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto) ||
|
r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto) ||
|
||||||
isSetRef(r.Source) || isSetRef(r.Destination)
|
isSetRef(r.Source) || isSetRef(r.Destination) ||
|
||||||
|
strings.HasPrefix(r.Source, "!") || strings.HasPrefix(r.Destination, "!")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipv6Unavailable reports whether r resolves to IPv6 while the backend's own IPv6
|
// ipv6Unavailable reports whether r resolves to IPv6 while the backend's own IPv6
|
||||||
|
|
@ -164,79 +169,63 @@ func dirAnyPlainLine(r *Rule) bool {
|
||||||
return r.Direction == DirAny && bareHostShape(r) && !ruleNeedsHook(r)
|
return r.Direction == DirAny && bareHostShape(r) && !ruleNeedsHook(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostNeedsHook reports whether an address-bearing rule with no port must be
|
// shapeNeedsHook reports whether a rule's shape overflows every native CSF/APF
|
||||||
// injected through the raw-iptables hook because a csf/apf trust file has no form
|
// config form and must be injected through the raw-iptables hook. The native forms
|
||||||
// for it: a source+destination pair (both store a single address) or a host
|
// are narrow — a plain trust-file line holds one address, matches both directions,
|
||||||
// pinned to a concrete tcp/udp protocol (the plain line is all-protocol and the
|
// and is all-protocol; an advanced line holds exactly one address field and one
|
||||||
// advanced rule requires a port). Both are expressed natively as an iptables
|
// port-flow field and requires both; the conf lists key on a port or an icmp type —
|
||||||
// rule. A single-address all-protocol host is not covered here — a one-way one is
|
// while iptables expresses every overflow directly (`-s` with `-d`, `--sport` with
|
||||||
// routed by bareHostOneWay, a bidirectional one is a native plain line — and ICMP
|
// `--dport`, a bare `-p tcp -j ACCEPT`), so these shapes are hooked rather than
|
||||||
// keeps its own handling (CSF.nativeICMP, APF.isConfRule), so it is excluded. Shared
|
// rejected: the alternative is failing on a rule the firewall can enforce.
|
||||||
// by CSF and APF.
|
|
||||||
func hostNeedsHook(r *Rule) bool {
|
|
||||||
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.
|
|
||||||
//
|
//
|
||||||
// hostNeedsHook covers the portless address shapes and bows out once a rule carries
|
// Feature-based routing (state, interface, logging, rate limiting, ICMPv6,
|
||||||
// a port, so this predicate is what routes their ported counterparts. Only the
|
// hook-only transports, set references, negation) is ruleNeedsHook's job, and the
|
||||||
// protocols an iptables port or icmp match accepts are routed here: a port on
|
// backend-specific connlimit, ICMP and port-list shapes stay in each backend's
|
||||||
// ProtocolAny is inexpressible in iptables too, so it stays on the native path and is
|
// needsHook; ICMP is excluded from every test here because both backends route it
|
||||||
// rejected there by iptablesRuleValid rather than reaching the hook and failing there.
|
// there (CSF's typed advanced rule, APF.isConfRule). Shared by CSF and APF.
|
||||||
// Shared by CSF and APF.
|
func shapeNeedsHook(r *Rule) bool {
|
||||||
func advRuleNeedsHook(r *Rule) bool {
|
// A one-way bare host: a plain line is bidirectional and an advanced rule
|
||||||
if !onProtocolAxis(r.Proto) && !r.Proto.IsICMP() {
|
// requires a port, so a concrete-direction bare host has no native form.
|
||||||
return false
|
if bareHostOneWay(r) {
|
||||||
}
|
|
||||||
// One port-flow field: a source port and a destination port cannot share it.
|
|
||||||
if r.HasPorts() && r.HasSourcePorts() {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// An advanced rule requires an address, so a bare source-port match has no
|
// Portless address shapes no trust file expresses.
|
||||||
// advanced form at all; iptables matches --sport on its own.
|
if !r.Proto.IsICMP() && !r.HasPorts() && !r.HasSourcePorts() {
|
||||||
if r.HasSourcePorts() && r.Source == "" && r.Destination == "" {
|
// A source+destination pair has no single-address advanced/plain form.
|
||||||
return true
|
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
|
// Advanced-line overflows, on the protocols an iptables port or icmp match
|
||||||
// form is at stake, so a rule with neither a port nor an icmp match — which never
|
// accepts: a port on ProtocolAny is inexpressible in iptables too, so it stays
|
||||||
// reaches MarshalAdvRule — is left to hostNeedsHook.
|
// on the native path and is rejected there by iptablesRuleValid rather than
|
||||||
if r.Source != "" && r.Destination != "" {
|
// reaching the hook and failing there.
|
||||||
return r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()
|
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
|
// 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.
|
||||||
// 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 {
|
|
||||||
return r.Source == "" && r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && !r.Proto.IsICMP()
|
return r.Source == "" && r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && !r.Proto.IsICMP()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,13 +296,13 @@ func hookCommand(fam Family) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// shellSafeToken quotes a token so /bin/sh passes it through verbatim. The
|
// shellSafeToken quotes a token so /bin/sh passes it through verbatim. The
|
||||||
// iptables marshaller quotes free-text fields (a comment, a log prefix) with
|
// iptables marshaller quotes free-text fields (a comment, a log prefix) for an
|
||||||
// strconv.Quote — Go double-quoting, which is right for an iptables-restore file
|
// iptables-restore file, where double quotes are literal — but the hook is
|
||||||
// but NOT for the hook script, which /bin/sh sources: inside double quotes the
|
// sourced by /bin/sh, which expands $var, $(...) and backticks inside double
|
||||||
// shell still expands $var, $(...) and backticks. A token made of ordinary
|
// quotes. A token made of ordinary argument characters is returned bare for
|
||||||
// argument characters is returned bare for readability; anything else is wrapped
|
// readability; anything else is wrapped in single quotes (with any embedded
|
||||||
// in single quotes (with any embedded single quote escaped), which the shell
|
// single quote escaped), which the shell treats as a literal. shlex.Split
|
||||||
// treats as a literal. shlex.Split reverses either form on read-back.
|
// reverses either form on read-back.
|
||||||
func shellSafeToken(tok string) string {
|
func shellSafeToken(tok string) string {
|
||||||
safe := tok != ""
|
safe := tok != ""
|
||||||
for _, r := range tok {
|
for _, r := range tok {
|
||||||
|
|
@ -331,11 +320,65 @@ func shellSafeToken(tok string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// rulesToLines encodes a rule as the raw command line(s) to inject, for the
|
// rulesToLines encodes a rule as the raw command line(s) to inject, for the
|
||||||
// families the backend enforces (see writeFamilies).
|
// families the backend enforces (see writeFamilies). A family-agnostic rule
|
||||||
|
// that references an address set is pinned to the set's family instead: an
|
||||||
|
// ipset is single-family, so the opposite-family line would fail every time
|
||||||
|
// the firewall sources the hook.
|
||||||
func (h *hookScript) rulesToLines(r *Rule) ([]string, error) {
|
func (h *hookScript) rulesToLines(r *Rule) ([]string, error) {
|
||||||
|
if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) {
|
||||||
|
fam, err := h.setRefFamily(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fam == IPv6 && !h.ipv6Enabled {
|
||||||
|
return nil, fmt.Errorf("rule references an IPv6 address set while IPv6 is disabled: %w", ErrUnsupported)
|
||||||
|
}
|
||||||
|
return h.linesForFamilies(r, []Family{fam})
|
||||||
|
}
|
||||||
return h.linesForFamilies(r, h.writeFamilies(r))
|
return h.linesForFamilies(r, h.writeFamilies(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setRefFamily resolves the single family of the address set(s) a rule
|
||||||
|
// references from the hook's ipset lines. A set that is not in the hook, or a
|
||||||
|
// source/destination pair naming sets of different families, cannot produce a
|
||||||
|
// loadable line, so both are errors.
|
||||||
|
func (h *hookScript) setRefFamily(r *Rule) (Family, error) {
|
||||||
|
sets, err := h.getAddressSets()
|
||||||
|
if err != nil {
|
||||||
|
return FamilyAny, err
|
||||||
|
}
|
||||||
|
fam := FamilyAny
|
||||||
|
for _, ref := range []string{r.Source, r.Destination} {
|
||||||
|
if !isSetRef(ref) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, bare := splitAddrNeg(strings.TrimSpace(ref))
|
||||||
|
name := strings.TrimPrefix(bare, "@")
|
||||||
|
var found *AddressSet
|
||||||
|
for _, s := range sets {
|
||||||
|
if s.Name == name {
|
||||||
|
found = s
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
return FamilyAny, fmt.Errorf("rule references unknown address set %q", name)
|
||||||
|
}
|
||||||
|
sf := found.Family
|
||||||
|
if sf != IPv6 {
|
||||||
|
sf = IPv4
|
||||||
|
}
|
||||||
|
if fam != FamilyAny && sf != fam {
|
||||||
|
return FamilyAny, fmt.Errorf("rule references address sets of different families")
|
||||||
|
}
|
||||||
|
fam = sf
|
||||||
|
}
|
||||||
|
if fam == FamilyAny {
|
||||||
|
fam = IPv4
|
||||||
|
}
|
||||||
|
return fam, nil
|
||||||
|
}
|
||||||
|
|
||||||
// removalLines encodes every hook line a rule could occupy, across both families
|
// removalLines encodes every hook line a rule could occupy, across both families
|
||||||
// regardless of the backend's IPv6 setting. Removal sweeps the wider set so an
|
// regardless of the backend's IPv6 setting. Removal sweeps the wider set so an
|
||||||
// ip6tables line written while IPv6 was enabled — or added by hand — is still
|
// ip6tables line written while IPv6 was enabled — or added by hand — is still
|
||||||
|
|
@ -390,6 +433,26 @@ func ruleMatchesAny(e *Rule, targets []*Rule) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// orphanLogMatchesAny reports whether e is a LOG line whose action partner is
|
||||||
|
// gone from the hook and whose logged rule is named by one of targets, so a
|
||||||
|
// removal still clears the stray line.
|
||||||
|
func orphanLogMatchesAny(e *Rule, targets []*Rule) bool {
|
||||||
|
if e.Action != ActionInvalid || !e.Log {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
if !t.Log {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tl := *t
|
||||||
|
tl.Action = ActionInvalid
|
||||||
|
if e.Equal(&tl, true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// parseLine decodes an injected command line back into the rule it represents
|
// parseLine decodes an injected command line back into the rule it represents
|
||||||
// (one line, so a LOG line yields a rule with Log set and no action), reporting
|
// (one line, so a LOG line yields a rule with Log set and no action), reporting
|
||||||
// whether the line is one this backend recognizes.
|
// whether the line is one this backend recognizes.
|
||||||
|
|
@ -429,9 +492,8 @@ func (h *hookScript) commandLines() ([]string, error) {
|
||||||
}
|
}
|
||||||
var cmds []string
|
var cmds []string
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
t := strings.TrimSpace(line)
|
if isHookRuleLine(line) {
|
||||||
if strings.HasPrefix(t, "iptables ") || strings.HasPrefix(t, "ip6tables ") {
|
cmds = append(cmds, strings.TrimSpace(line))
|
||||||
cmds = append(cmds, t)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cmds, nil
|
return cmds, nil
|
||||||
|
|
@ -472,8 +534,9 @@ func (h *hookScript) readHookLines() ([]string, bool, error) {
|
||||||
return strings.Split(content, "\n"), true, nil
|
return strings.Split(content, "\n"), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeHook atomically replaces the hook with lines, giving a freshly created
|
// writeHook atomically replaces the hook with lines. A freshly created hook
|
||||||
// hook a shebang, and keeps it executable so the firewall can source it.
|
// gets a shebang and the executable hookPerm; an existing hook keeps its own
|
||||||
|
// mode and ownership (see writeConfigFile).
|
||||||
func (h *hookScript) writeHook(lines []string, existed bool) error {
|
func (h *hookScript) writeHook(lines []string, existed bool) error {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if !existed {
|
if !existed {
|
||||||
|
|
@ -483,8 +546,6 @@ func (h *hookScript) writeHook(lines []string, existed bool) error {
|
||||||
b.WriteString(l)
|
b.WriteString(l)
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
}
|
}
|
||||||
// Preserve an existing hook's mode and ownership; a freshly created hook gets
|
|
||||||
// the executable hookPerm so the firewall can source it.
|
|
||||||
if err := writeConfigFile(h.hookPath, []byte(b.String()), h.hookPerm); err != nil {
|
if err := writeConfigFile(h.hookPath, []byte(b.String()), h.hookPerm); err != nil {
|
||||||
return fmt.Errorf("failed to move updated hook into place: %s", err)
|
return fmt.Errorf("failed to move updated hook into place: %s", err)
|
||||||
}
|
}
|
||||||
|
|
@ -494,13 +555,15 @@ func (h *hookScript) writeHook(lines []string, existed bool) error {
|
||||||
// edit adds or removes a rule's command line(s) directly in the hook. Adding
|
// edit adds or removes a rule's command line(s) directly in the hook. Adding
|
||||||
// matches on the exact deterministic line the marshaller emits, so the library's
|
// matches on the exact deterministic line the marshaller emits, so the library's
|
||||||
// own tagged line is written once and re-adds are idempotent. Removal instead
|
// own tagged line is written once and re-adds are idempotent. Removal instead
|
||||||
// matches on the underlying rule: a line is dropped when the rule it encodes is
|
// matches on the underlying rule: a hook rule is dropped when it is the same rule
|
||||||
// the same as one r resolves to, ignoring the comment tag, so a copy of the rule a
|
// as one r resolves to, ignoring the comment tag, so a copy of the rule a
|
||||||
// customer added under a different comment (or none) is cleared too — the comment
|
// customer added under a different comment (or none) is cleared too — the comment
|
||||||
// is not part of rule identity. A logged rule's LOG and action lines are matched
|
// is not part of rule identity. A LOG line and the action line under it are
|
||||||
// independently, exactly as they were written. It preserves every other hook
|
// matched as the one logged rule they encode, never independently: a logged rule
|
||||||
// line — user-authored shell and rules alike — and reports whether the hook
|
// and its unlogged twin are distinct rules, and removing one must not strip the
|
||||||
// changed. Adding to an absent hook creates it; removing from one is a no-op.
|
// other's lines. It preserves every other hook line — user-authored shell and
|
||||||
|
// rules alike — and reports whether the hook changed. Adding to an absent hook
|
||||||
|
// creates it; removing from one is a no-op.
|
||||||
func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
|
func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
|
||||||
// An add writes only the families the backend enforces; a removal sweeps both, so
|
// An add writes only the families the backend enforces; a removal sweeps both, so
|
||||||
// a stale ip6tables line does not outlive an IPv6 switch-off (see writeFamilies).
|
// a stale ip6tables line does not outlive an IPv6 switch-off (see writeFamilies).
|
||||||
|
|
@ -520,23 +583,50 @@ func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
|
||||||
|
|
||||||
changed := false
|
changed := false
|
||||||
if remove {
|
if remove {
|
||||||
// Parse each desired line back into the rule it encodes; a hook line whose own
|
// Parse each desired line back into the rule it encodes — the round trip
|
||||||
// rule matches one of these (comment ignored, see Rule.Equal) is dropped. A line
|
// normalizes field spellings — and coalesce each LOG line with its action
|
||||||
// that is not a rule the hook recognizes (foreign shell, a comment) never parses
|
// line so the targets are logical rules. A desired line that fails to
|
||||||
// and is preserved.
|
// parse breaks the marshal/parse round trip the removal match depends on,
|
||||||
var targets []*Rule
|
// so it is an error rather than a silent no-op.
|
||||||
|
var parsed []*Rule
|
||||||
for _, l := range desired {
|
for _, l := range desired {
|
||||||
if tr, ok := h.parseLine(l); ok {
|
tr, ok := h.parseLine(l)
|
||||||
targets = append(targets, tr)
|
if !ok {
|
||||||
|
return false, fmt.Errorf("hook removal target does not round-trip: %q", l)
|
||||||
}
|
}
|
||||||
|
parsed = append(parsed, tr)
|
||||||
}
|
}
|
||||||
|
targets := coalesceLoggedRules(parsed)
|
||||||
next := lines[:0:0]
|
next := lines[:0:0]
|
||||||
for _, l := range lines {
|
for i := 0; i < len(lines); i++ {
|
||||||
if er, ok := h.parseLine(l); ok && ruleMatchesAny(er, targets) {
|
er, ok := h.parseLine(lines[i])
|
||||||
changed = true
|
if !ok {
|
||||||
|
next = append(next, lines[i])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
next = append(next, l)
|
// Pair a LOG line with the action line directly under it, mirroring
|
||||||
|
// getRules' coalescing, so the pair is kept or dropped as one rule.
|
||||||
|
var partner *Rule
|
||||||
|
if i+1 < len(lines) {
|
||||||
|
partner, _ = h.parseLine(lines[i+1])
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case logPartner(er, partner):
|
||||||
|
if ruleMatchesAny(mergeLogPair(er, partner), targets) {
|
||||||
|
changed = true
|
||||||
|
} else {
|
||||||
|
next = append(next, lines[i], lines[i+1])
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
case orphanLogMatchesAny(er, targets):
|
||||||
|
// A stray LOG line whose action partner was hand-edited away
|
||||||
|
// still belongs to the logged rule named by the removal.
|
||||||
|
changed = true
|
||||||
|
case ruleMatchesAny(er, targets):
|
||||||
|
changed = true
|
||||||
|
default:
|
||||||
|
next = append(next, lines[i])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !changed {
|
if !changed {
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|
@ -562,6 +652,191 @@ func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
|
||||||
return true, h.writeHook(lines, existed)
|
return true, h.writeHook(lines, existed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- NAT rules (raw nat-table commands in the hook) --------------------------
|
||||||
|
//
|
||||||
|
// csf.redirect holds exactly two destination-NAT shapes, so every other NAT rule
|
||||||
|
// — source NAT, an interface-bound or source-matched translation, a port
|
||||||
|
// range/list — is injected as a raw `iptables -t nat` command through the same
|
||||||
|
// hook that carries the filter rules. csf flushes the v4 nat table on every
|
||||||
|
// (re)start whenever the kernel provides one (its Config.pm probes `-t nat -L
|
||||||
|
// POSTROUTING` and sets NAT=1) and sources the pre-hook afterwards, so the
|
||||||
|
// injected lines are applied exactly once per load; the v6 nat flush is guarded
|
||||||
|
// by IPV6, which is the same hazard natWriteFamilies narrows writes for. APF
|
||||||
|
// expresses the same escape hatch with its own preroute.rules/postroute.rules
|
||||||
|
// files instead of the hook.
|
||||||
|
|
||||||
|
// natRuleFamilies lists every address family a NAT rule's raw nat lines can
|
||||||
|
// occupy: a rule pinned to a family (by an address or its Family field) touches
|
||||||
|
// only that command; a family-agnostic rule (e.g. a portless masquerade) spans
|
||||||
|
// both v4 and v6. It is the full set, which is what removal must sweep;
|
||||||
|
// natWriteFamilies narrows it to the families the backend enforces. Shared by
|
||||||
|
// CSF's hook NAT lines and APF's routing files.
|
||||||
|
func natRuleFamilies(r *NATRule) []Family {
|
||||||
|
switch r.impliedFamily() {
|
||||||
|
case IPv4:
|
||||||
|
return []Family{IPv4}
|
||||||
|
case IPv6:
|
||||||
|
return []Family{IPv6}
|
||||||
|
default:
|
||||||
|
return []Family{IPv4, IPv6}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// natWriteFamilies lists the families a NAT rule's lines are written for. With
|
||||||
|
// the backend's own IPv6 handling off a family-agnostic rule is written for IPv4
|
||||||
|
// only: neither csf nor apf flushes the v6 nat table while IPv6 is disabled, so
|
||||||
|
// an injected ip6tables line would be re-appended on every reload and outlive
|
||||||
|
// its own removal. Removal still sweeps both families (natRuleFamilies) so a
|
||||||
|
// stale v6 line does not survive an IPv6 switch-off. It is the NAT analog of
|
||||||
|
// hookScript.writeFamilies. Shared by CSF and APF.
|
||||||
|
func natWriteFamilies(ipv6Enabled bool, r *NATRule) []Family {
|
||||||
|
fams := natRuleFamilies(r)
|
||||||
|
if ipv6Enabled || len(fams) == 1 {
|
||||||
|
return fams
|
||||||
|
}
|
||||||
|
return []Family{IPv4}
|
||||||
|
}
|
||||||
|
|
||||||
|
// natLine encodes a NAT rule as one raw nat-table command line for a family.
|
||||||
|
// Each marshalled line is re-tokenized and re-quoted shell-safely, as with
|
||||||
|
// linesForFamilies, because the hook is sourced by /bin/sh.
|
||||||
|
func (h *hookScript) natLine(r *NATRule, fam Family) (string, error) {
|
||||||
|
rc := *r
|
||||||
|
rc.Family = fam
|
||||||
|
ipt := &IPTables{rulePrefix: h.rulePrefix}
|
||||||
|
spec, err := ipt.MarshalNATRule(&rc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tokens, err := shlex.Split(spec, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for i, t := range tokens {
|
||||||
|
tokens[i] = shellSafeToken(t)
|
||||||
|
}
|
||||||
|
return hookCommand(fam) + " -t nat " + strings.Join(tokens, " "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNATLine decodes a raw nat command line back into the NATRule it
|
||||||
|
// represents, reporting whether the line is one this backend recognizes. The
|
||||||
|
// iptables NAT parser derives HasPrefix from the line's comment tag, so a rule
|
||||||
|
// this library wrote reports it and a hand-added line does not. A `-t nat` line
|
||||||
|
// never doubles as a filter rule: parseLine's chain check (INPUT/OUTPUT/FORWARD)
|
||||||
|
// rejects it, so the two line kinds stay disjoint in the same hook.
|
||||||
|
func (h *hookScript) parseNATLine(line string) (*NATRule, bool) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
var fam Family
|
||||||
|
var rest string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "iptables -t nat "):
|
||||||
|
fam, rest = IPv4, strings.TrimPrefix(line, "iptables -t nat ")
|
||||||
|
case strings.HasPrefix(line, "ip6tables -t nat "):
|
||||||
|
fam, rest = IPv6, strings.TrimPrefix(line, "ip6tables -t nat ")
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
ipt := &IPTables{rulePrefix: h.rulePrefix}
|
||||||
|
r, err := ipt.UnmarshalNATRule(rest, fam)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNATRules parses the raw nat-table rules the hook carries. Every such line
|
||||||
|
// is returned, including any a user authored by hand, so the library reconciles
|
||||||
|
// the hook's real state.
|
||||||
|
func (h *hookScript) getNATRules() ([]*NATRule, error) {
|
||||||
|
lines, _, err := h.readHookLines()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rules []*NATRule
|
||||||
|
for _, line := range lines {
|
||||||
|
if r, ok := h.parseNATLine(line); ok {
|
||||||
|
rules = append(rules, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// editNAT adds or removes a NAT rule's raw nat command line(s) in the hook,
|
||||||
|
// mirroring edit for filter rules: an add writes only the families the backend
|
||||||
|
// enforces and dedups against an equivalent existing line — the exact text, or
|
||||||
|
// the same translation under EqualForRemoval, which stays family-aware so a
|
||||||
|
// family-scoped edit leaves an opposite-family twin alone — while a removal
|
||||||
|
// sweeps both families. Every other hook line is preserved; it reports whether
|
||||||
|
// the hook changed.
|
||||||
|
func (h *hookScript) editNAT(r *NATRule, remove bool) (bool, error) {
|
||||||
|
fams := natRuleFamilies(r)
|
||||||
|
if !remove {
|
||||||
|
fams = natWriteFamilies(h.ipv6Enabled, r)
|
||||||
|
}
|
||||||
|
want := make([]string, 0, len(fams))
|
||||||
|
wantSet := make(map[string]bool, len(fams))
|
||||||
|
for _, fam := range fams {
|
||||||
|
line, err := h.natLine(r, fam)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
want = append(want, line)
|
||||||
|
wantSet[line] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, existed, err := h.readHookLines()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
next := make([]string, 0, len(lines)+len(want))
|
||||||
|
present := make(map[string]bool, len(want))
|
||||||
|
for _, raw := range lines {
|
||||||
|
body := strings.TrimSpace(raw)
|
||||||
|
matched := false
|
||||||
|
satisfied := ""
|
||||||
|
if wantSet[body] {
|
||||||
|
matched, satisfied = true, body
|
||||||
|
} else if existing, ok := h.parseNATLine(body); ok && existing.EqualForRemoval(r) {
|
||||||
|
matched = true
|
||||||
|
// The equivalent want-line is the one for this existing line's family;
|
||||||
|
// mark it satisfied so its duplicate is not appended below. Recompute it
|
||||||
|
// (rather than reuse body) since body is the existing spelling, not ours.
|
||||||
|
if line, lerr := h.natLine(r, existing.impliedFamily()); lerr == nil {
|
||||||
|
satisfied = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
if remove {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if satisfied != "" {
|
||||||
|
present[satisfied] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next = append(next, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if remove {
|
||||||
|
if !changed {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, h.writeHook(next, existed)
|
||||||
|
}
|
||||||
|
for _, line := range want {
|
||||||
|
if !present[line] {
|
||||||
|
next = append(next, line)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, h.writeHook(next, existed)
|
||||||
|
}
|
||||||
|
|
||||||
// --- address sets (ipset commands in the hook) -----------------------------
|
// --- address sets (ipset commands in the hook) -----------------------------
|
||||||
//
|
//
|
||||||
// CSF and APF have no native address-set construct, so the library persists a
|
// CSF and APF have no native address-set construct, so the library persists a
|
||||||
|
|
@ -654,7 +929,10 @@ func (h *hookScript) getAddressSets() ([]*AddressSet, error) {
|
||||||
}
|
}
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
f := strings.Fields(line)
|
f := strings.Fields(line)
|
||||||
if len(f) == 4 && f[0] == "ipset" && f[1] == "add" {
|
// A hand-authored add may carry trailing options (`timeout 300`, `-exist`);
|
||||||
|
// the entry itself is still the fourth field. Options are not modeled, so a
|
||||||
|
// rewrite of the set's block re-emits the entry without them.
|
||||||
|
if len(f) >= 4 && f[0] == "ipset" && f[1] == "add" {
|
||||||
if s, ok := sets[f[2]]; ok {
|
if s, ok := sets[f[2]]; ok {
|
||||||
s.Entries = append(s.Entries, f[3])
|
s.Entries = append(s.Entries, f[3])
|
||||||
}
|
}
|
||||||
|
|
@ -674,6 +952,12 @@ func (h *hookScript) getAddressSets() ([]*AddressSet, error) {
|
||||||
// rule still references it. Every other hook line — user shell, rules, other
|
// rule still references it. Every other hook line — user shell, rules, other
|
||||||
// sets — is preserved; it reports whether the hook changed.
|
// sets — is preserved; it reports whether the hook changed.
|
||||||
func (h *hookScript) editAddressSet(set *AddressSet, remove bool) (bool, error) {
|
func (h *hookScript) editAddressSet(set *AddressSet, remove bool) (bool, error) {
|
||||||
|
// hookIPSetName reports "" for every non-ipset line, so an unnamed set would
|
||||||
|
// match — and the keep-filter below would drop — every rule and user line in
|
||||||
|
// the hook.
|
||||||
|
if set.Name == "" {
|
||||||
|
return false, fmt.Errorf("an address set requires a name")
|
||||||
|
}
|
||||||
lines, existed, err := h.readHookLines()
|
lines, existed, err := h.readHookLines()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ func TestRuleNeedsHook(t *testing.T) {
|
||||||
// literal trust-file form, so it routes through the hook beside the ipset
|
// literal trust-file form, so it routes through the hook beside the ipset
|
||||||
// commands that create the set.
|
// commands that create the set.
|
||||||
{Family: IPv4, Source: "blocklist", Action: Drop},
|
{Family: IPv4, Source: "blocklist", Action: Drop},
|
||||||
|
// A negated address has no native form either: csf.pl passes an advanced
|
||||||
|
// line's s=/d= value verbatim to iptables and skips a plain "!"-prefixed
|
||||||
|
// line, so the hook's `! -s`/`! -d` is the only reliable spelling.
|
||||||
|
{Proto: TCP, Port: 22, Source: "!192.0.2.50/32", Action: Accept},
|
||||||
|
{Proto: TCP, Port: 22, Destination: "!192.0.2.51/32", Action: Accept},
|
||||||
|
{Source: "!192.0.2.52", Action: Accept},
|
||||||
}
|
}
|
||||||
for _, r := range needs {
|
for _, r := range needs {
|
||||||
require.True(t, ruleNeedsHook(r), "expected %+v to need the hook", *r)
|
require.True(t, ruleNeedsHook(r), "expected %+v to need the hook", *r)
|
||||||
|
|
@ -40,22 +46,36 @@ func TestRuleNeedsHook(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A csf/apf advanced line holds one address field and one port-flow field. A rule
|
// shapeNeedsHook routes every rule shape the csf/apf native forms cannot hold —
|
||||||
// that overflows either must reach the raw-iptables hook, which matches -s/-d and
|
// a one-way bare host, a portless source+destination pair or transport-pinned
|
||||||
// --sport/--dport together: before advRuleNeedsHook existed, AddRule fell through to
|
// host, an advanced-line address/port-flow overflow, and a bare protocol match —
|
||||||
// MarshalAdvRule and failed on a rule the firewall can enforce.
|
// to the raw-iptables hook, while the shapes a plain line, an advanced line, or a
|
||||||
func TestAdvRuleNeedsHook(t *testing.T) {
|
// conf list expresses (and the ICMP shapes each backend routes itself) stay off it.
|
||||||
// Shapes the single address / single port-flow field cannot hold.
|
func TestShapeNeedsHook(t *testing.T) {
|
||||||
|
// Shapes with no native csf/apf form.
|
||||||
needs := []*Rule{
|
needs := []*Rule{
|
||||||
// A source+destination pair carrying a port: hostNeedsHook bows out once a
|
// A one-way bare host: a plain line is bidirectional, an advanced rule needs a
|
||||||
// rule has a port, so this predicate is what routes it.
|
// port (an unset direction is DirInput, so the first is one-way too).
|
||||||
|
{Source: "1.2.3.4", Action: Accept},
|
||||||
|
{Direction: DirInput, Source: "1.2.3.4", Action: Drop},
|
||||||
|
{Direction: DirOutput, Destination: "1.2.3.4", Action: Accept},
|
||||||
|
// A portless source+destination pair: no single-address advanced/plain form.
|
||||||
|
{Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept, Direction: DirAny},
|
||||||
|
// A portless host pinned to a transport: the plain line is all-protocol and the
|
||||||
|
// advanced rule requires a port. TCPUDP names transports, so it counts.
|
||||||
|
{Proto: TCP, Source: "1.2.3.4", Action: Accept, Direction: DirAny},
|
||||||
|
{Proto: UDP, Destination: "1.2.3.4", Direction: DirOutput, Action: Accept},
|
||||||
|
{Proto: TCPUDP, Source: "192.0.2.1", Action: Accept, Direction: DirAny},
|
||||||
|
// A source+destination pair carrying a port or an ICMP match: the second
|
||||||
|
// address overflows the advanced line's single address field.
|
||||||
{Proto: TCP, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
{Proto: TCP, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
||||||
{Proto: TCPUDP, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
{Proto: TCPUDP, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
||||||
{Proto: TCP, SourcePort: 1234, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
{Proto: TCP, SourcePort: 1234, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
||||||
// A source+destination pair on an ICMP match, typed or not.
|
|
||||||
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
||||||
{Proto: ICMP, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
{Proto: ICMP, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
||||||
// A source port matched together with a destination port, addressed or not.
|
{Proto: ICMPv6, Source: "2001:db8::1", Destination: "2001:db8::2", Action: Accept},
|
||||||
|
// A source port matched together with a destination port, addressed or not:
|
||||||
|
// they cannot share the advanced line's single port-flow field.
|
||||||
{Proto: TCP, Port: 80, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
|
{Proto: TCP, Port: 80, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
|
||||||
{Proto: UDP, Port: 53, SourcePort: 53, Destination: "192.0.2.1", Action: Accept},
|
{Proto: UDP, Port: 53, SourcePort: 53, Destination: "192.0.2.1", Action: Accept},
|
||||||
{Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept},
|
{Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept},
|
||||||
|
|
@ -63,25 +83,37 @@ func TestAdvRuleNeedsHook(t *testing.T) {
|
||||||
// advanced form of any kind.
|
// advanced form of any kind.
|
||||||
{Proto: TCP, SourcePort: 1234, Action: Accept},
|
{Proto: TCP, SourcePort: 1234, Action: Accept},
|
||||||
{Proto: TCPUDP, SourcePort: 1234, Action: Drop},
|
{Proto: TCPUDP, SourcePort: 1234, Action: Drop},
|
||||||
|
// A bare protocol match: no address, no port, non-ICMP.
|
||||||
|
{Proto: TCP, Action: Accept},
|
||||||
|
{Proto: UDP, Action: Drop},
|
||||||
|
{Proto: ProtocolAny, Action: Accept},
|
||||||
|
{Proto: TCP, Direction: DirOutput, Action: Accept},
|
||||||
}
|
}
|
||||||
for _, r := range needs {
|
for _, r := range needs {
|
||||||
require.True(t, advRuleNeedsHook(r), "expected %+v to need the hook", *r)
|
require.True(t, shapeNeedsHook(r), "expected %+v to need the hook", *r)
|
||||||
}
|
}
|
||||||
// Shapes the advanced line holds, or that another predicate routes.
|
// Shapes a native form holds, or that another route owns.
|
||||||
native := []*Rule{
|
native := []*Rule{
|
||||||
|
// A bidirectional bare host: the plain trust-file line itself.
|
||||||
|
{Direction: DirAny, Source: "1.2.3.4", Action: Accept},
|
||||||
// One address, one port-flow field: the advanced rule itself.
|
// One address, one port-flow field: the advanced rule itself.
|
||||||
{Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept},
|
{Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept},
|
||||||
{Proto: TCP, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
|
{Proto: TCP, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
|
||||||
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.1", Action: Accept},
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.1", Action: Accept},
|
||||||
// A portless source+destination pair belongs to hostNeedsHook.
|
// An address-less port rule: a native conf port list.
|
||||||
{Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
{Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
// ICMP shapes with at most one address are routed by each backend's own
|
||||||
|
// needsHook (csf's typed advanced rule, apf's conf type lists), not here.
|
||||||
|
{Proto: ICMP, Source: "1.2.3.4", Action: Accept},
|
||||||
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
{Proto: ICMPv6, Action: Accept},
|
||||||
// A port on ProtocolAny is inexpressible in iptables too, so it must keep its
|
// A port on ProtocolAny is inexpressible in iptables too, so it must keep its
|
||||||
// own rejection rather than reach the hook and fail there.
|
// own rejection rather than reach the hook and fail there.
|
||||||
{Proto: ProtocolAny, Port: 80, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
|
{Proto: ProtocolAny, Port: 80, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
|
||||||
{Proto: ProtocolAny, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
{Proto: ProtocolAny, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
|
||||||
}
|
}
|
||||||
for _, r := range native {
|
for _, r := range native {
|
||||||
require.False(t, advRuleNeedsHook(r), "expected %+v not to route to the hook", *r)
|
require.False(t, shapeNeedsHook(r), "expected %+v not to route to the hook", *r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,35 +160,6 @@ func TestIPv6UnavailableGate(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bareProtoNeedsHook routes a portless, addressless non-ICMP match to the hook —
|
|
||||||
// the shape CSF/APF cannot express natively but iptables applies directly — while
|
|
||||||
// leaving every rule that carries an address, a port, or an ICMP protocol on its
|
|
||||||
// own native/ICMP path.
|
|
||||||
func TestBareProtoNeedsHook(t *testing.T) {
|
|
||||||
// Bare protocol matches with no address and no port go to the hook.
|
|
||||||
needs := []*Rule{
|
|
||||||
{Proto: TCP, Action: Accept},
|
|
||||||
{Proto: UDP, Action: Drop},
|
|
||||||
{Proto: ProtocolAny, Action: Accept},
|
|
||||||
{Proto: TCP, Direction: DirOutput, Action: Accept},
|
|
||||||
}
|
|
||||||
for _, r := range needs {
|
|
||||||
require.True(t, bareProtoNeedsHook(r), "expected %+v to route to the hook", *r)
|
|
||||||
}
|
|
||||||
// A port, an address, or an ICMP protocol keeps the rule off this path.
|
|
||||||
native := []*Rule{
|
|
||||||
{Proto: TCP, Port: 22, Action: Accept},
|
|
||||||
{Proto: TCP, SourcePort: 1234, Action: Accept},
|
|
||||||
{Proto: TCP, Source: "1.2.3.4", Action: Accept},
|
|
||||||
{Proto: ProtocolAny, Destination: "1.2.3.4", Action: Accept},
|
|
||||||
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
|
||||||
{Proto: ICMPv6, Action: Accept},
|
|
||||||
}
|
|
||||||
for _, r := range native {
|
|
||||||
require.False(t, bareProtoNeedsHook(r), "expected %+v to stay off the hook route", *r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two rules that are Equal (port-set order is not part of rule identity) must
|
// Two rules that are Equal (port-set order is not part of rule identity) must
|
||||||
// inject the same command line, so a second add is a no-op and a remove using a
|
// inject the same command line, so a second add is a no-op and a remove using a
|
||||||
// reordered port set still finds the rule. The hook script matches on the exact
|
// reordered port set still finds the rule. The hook script matches on the exact
|
||||||
|
|
@ -513,33 +516,6 @@ func TestBareHostOneWay(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostNeedsHook selects the portless address rules a csf/apf trust file cannot
|
|
||||||
// express — a concrete tcp/udp host or a source+destination pair — so AddRule
|
|
||||||
// diverts them to the raw-iptables hook instead of rejecting them. An all-
|
|
||||||
// protocol single-address host (a native plain line or a bareHostOneWay hook
|
|
||||||
// rule), a port-bearing rule, an address-less rule, and ICMP stay off this path.
|
|
||||||
func TestHostNeedsHook(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
rule *Rule
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"tcp host no port", &Rule{Proto: TCP, Source: "1.2.3.4"}, true},
|
|
||||||
{"udp host no port outbound", &Rule{Proto: UDP, Destination: "1.2.3.4", Direction: DirOutput}, true},
|
|
||||||
{"source and destination", &Rule{Source: "1.2.3.4", Destination: "5.6.7.8"}, true},
|
|
||||||
{"source and destination with proto", &Rule{Proto: TCP, Source: "1.2.3.4", Destination: "5.6.7.8"}, true},
|
|
||||||
{"all-protocol single host", &Rule{Source: "1.2.3.4"}, false},
|
|
||||||
{"tcp host with port", &Rule{Proto: TCP, Port: 22, Source: "1.2.3.4"}, false},
|
|
||||||
{"tcp host with source port", &Rule{Proto: TCP, SourcePort: 22, Source: "1.2.3.4"}, false},
|
|
||||||
{"address-less port rule", &Rule{Proto: TCP, Port: 22}, false},
|
|
||||||
{"icmp host", &Rule{Proto: ICMP, Source: "1.2.3.4"}, false},
|
|
||||||
{"icmpv6 source and destination", &Rule{Proto: ICMPv6, Source: "2001:db8::1", Destination: "2001:db8::2"}, false},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
require.Equal(t, c.want, hostNeedsHook(c.rule), c.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A set reference is not a literal host, so bareHostShape must reject it (else
|
// A set reference is not a literal host, so bareHostShape must reject it (else
|
||||||
// APF/CSF would write the set name into a trust file) while ruleNeedsHook routes
|
// APF/CSF would write the set name into a trust file) while ruleNeedsHook routes
|
||||||
// it to the hook. A literal address keeps the opposite verdicts.
|
// it to the hook. A literal address keeps the opposite verdicts.
|
||||||
|
|
@ -737,3 +713,285 @@ func TestHookScriptRemoveSweepsV6AfterIPv6Disabled(t *testing.T) {
|
||||||
"a stale ip6tables line must be swept on removal, not stranded in the hook")
|
"a stale ip6tables line must be swept on removal, not stranded in the hook")
|
||||||
require.NotContains(t, string(data), "iptables -A ")
|
require.NotContains(t, string(data), "iptables -A ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An unnamed set must be refused outright: hookIPSetName reports "" for every
|
||||||
|
// non-ipset line, so an empty name would match — and a rewrite would drop — every
|
||||||
|
// rule and user-authored line in the hook.
|
||||||
|
func TestHookAddressSetEmptyNamePreservesHook(t *testing.T) {
|
||||||
|
h := newTestHook(t)
|
||||||
|
_, err := h.editAddressSet(&AddressSet{Name: "keepme", Family: IPv4, Type: SetHashIP, Entries: []string{"192.0.2.1"}}, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 2299, Action: Accept, State: StateNew}, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
before, err := os.ReadFile(h.hookPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, remove := range []bool{true, false} {
|
||||||
|
changed, err := h.editAddressSet(&AddressSet{Name: ""}, remove)
|
||||||
|
require.Error(t, err, "an unnamed set must be refused (remove=%v)", remove)
|
||||||
|
require.False(t, changed)
|
||||||
|
}
|
||||||
|
after, err := os.ReadFile(h.hookPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, string(before), string(after), "the hook must be untouched after a refused edit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A logged rule and its unlogged twin are distinct rules: removing one must not
|
||||||
|
// strip the other's lines. The LOG line and its action line are matched as the one
|
||||||
|
// logged rule they encode, so the unlogged target matches neither.
|
||||||
|
func TestHookRemoveLoggedAndUnloggedAreDistinct(t *testing.T) {
|
||||||
|
logged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"}
|
||||||
|
unlogged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, State: StateNew}
|
||||||
|
|
||||||
|
// Hook holds only the logged pair; removing the unlogged twin is a no-op.
|
||||||
|
h := newTestHook(t)
|
||||||
|
_, err := h.edit(logged, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
unloggedTwin := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept}
|
||||||
|
changed, err := h.edit(unloggedTwin, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, changed, "removing the unlogged twin must not touch the logged pair")
|
||||||
|
rules, err := h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rules, 1)
|
||||||
|
require.True(t, rules[0].Log, "the logged rule must survive intact")
|
||||||
|
|
||||||
|
// Hook holds only the unlogged rule; removing the logged twin is a no-op.
|
||||||
|
h2 := newTestHook(t)
|
||||||
|
_, err = h2.edit(unlogged, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
changed, err = h2.edit(logged, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, changed, "removing the logged twin must not touch the unlogged rule")
|
||||||
|
|
||||||
|
// Removing the logged rule itself clears both of its lines.
|
||||||
|
changed, err = h.edit(logged, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
rules, err = h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, rules)
|
||||||
|
data, err := os.ReadFile(h.hookPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(data), "-j LOG", "the LOG line must be removed with its action line")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stray LOG line whose action partner was hand-edited away still belongs to the
|
||||||
|
// logged rule, so removing that rule sweeps it rather than stranding a live kernel
|
||||||
|
// LOG rule the library no longer reports.
|
||||||
|
func TestHookRemoveSweepsOrphanLogLine(t *testing.T) {
|
||||||
|
h := newTestHook(t)
|
||||||
|
logged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"}
|
||||||
|
_, err := h.edit(logged, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Hand-remove the action line, leaving the LOG line orphaned.
|
||||||
|
data, err := os.ReadFile(h.hookPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var kept []string
|
||||||
|
for _, l := range strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") {
|
||||||
|
if strings.Contains(l, "-j ACCEPT") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, l)
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(h.hookPath, []byte(strings.Join(kept, "\n")+"\n"), 0700))
|
||||||
|
|
||||||
|
changed, err := h.edit(logged, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed, "the orphan LOG line must be swept")
|
||||||
|
data, err = os.ReadFile(h.hookPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(data), "-j LOG")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hand-authored add line may carry trailing options (`timeout 300`, `-exist`);
|
||||||
|
// the entry is still captured, so a rewrite of the set's block re-emits it instead
|
||||||
|
// of silently dropping the user's entry.
|
||||||
|
func TestHookAddressSetOptionedAddLineKeepsEntry(t *testing.T) {
|
||||||
|
h := newTestHook(t)
|
||||||
|
body := "#!/bin/sh\n" +
|
||||||
|
"ipset create blocklist hash:ip family inet -exist\n" +
|
||||||
|
"ipset flush blocklist\n" +
|
||||||
|
"ipset add blocklist 203.0.113.9 timeout 300\n"
|
||||||
|
require.NoError(t, os.WriteFile(h.hookPath, []byte(body), 0700))
|
||||||
|
|
||||||
|
sets, err := h.getAddressSets()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, sets, 1)
|
||||||
|
require.Equal(t, []string{"203.0.113.9"}, sets[0].Entries)
|
||||||
|
|
||||||
|
// A rewrite of the block (adding a second entry) keeps the optioned entry.
|
||||||
|
changed, err := h.editAddressSetEntry("blocklist", "198.51.100.7", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
sets, err = h.getAddressSets()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.ElementsMatch(t, []string{"203.0.113.9", "198.51.100.7"}, sets[0].Entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A family-agnostic set-referencing rule is pinned to the set's family: an ipset
|
||||||
|
// is single-family, so the opposite-family line would fail every time the firewall
|
||||||
|
// sources the hook. A reference to a set the hook does not carry is an error, and
|
||||||
|
// an IPv6 set is unusable while the backend's IPv6 handling is off.
|
||||||
|
func TestHookSetRefPinsFamilyAnyToSetFamily(t *testing.T) {
|
||||||
|
h := newTestHook(t)
|
||||||
|
h.ipv6Enabled = true
|
||||||
|
_, err := h.editAddressSet(&AddressSet{Name: "v6drop", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}}, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
lines, err := h.rulesToLines(&Rule{Source: "v6drop", Action: Drop})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, lines, 1, "a family-agnostic set rule must be written for the set's family only")
|
||||||
|
require.True(t, strings.HasPrefix(lines[0], "ip6tables "), "want an ip6tables line, got %q", lines[0])
|
||||||
|
|
||||||
|
_, err = h.rulesToLines(&Rule{Source: "missing", Action: Drop})
|
||||||
|
require.Error(t, err, "a reference to an unknown set must be an error")
|
||||||
|
|
||||||
|
h.ipv6Enabled = false
|
||||||
|
_, err = h.rulesToLines(&Rule{Source: "v6drop", Action: Drop})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported, "an IPv6 set is unusable while IPv6 is off")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hook carries NAT rules csf.redirect cannot hold as raw `-t nat` command
|
||||||
|
// lines: an add round-trips through getNATRules, a re-add is idempotent, and a
|
||||||
|
// removal drops the line while leaving filter rules and user shell in place.
|
||||||
|
func TestHookScriptNATRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &hookScript{
|
||||||
|
rulePrefix: "go_firewall",
|
||||||
|
hookPath: filepath.Join(dir, "csfpre.sh"),
|
||||||
|
hookPerm: 0700,
|
||||||
|
ipv6Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []*NATRule{
|
||||||
|
{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"},
|
||||||
|
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
|
||||||
|
{Kind: DNAT, Family: IPv4, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "10.0.0.5"},
|
||||||
|
{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 8080, Source: "192.0.2.0/24", ToPort: 80},
|
||||||
|
}
|
||||||
|
for _, r := range cases {
|
||||||
|
changed, err := h.editNAT(r, false)
|
||||||
|
require.NoError(t, err, "add %+v", *r)
|
||||||
|
require.True(t, changed)
|
||||||
|
|
||||||
|
changed, err = h.editNAT(r, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, changed, "re-adding %+v must be idempotent", *r)
|
||||||
|
|
||||||
|
got, err := h.getNATRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1, "the added rule must read back exactly once")
|
||||||
|
require.True(t, got[0].Equal(r), "read-back mismatch: want %+v got %+v", *r, *got[0])
|
||||||
|
require.True(t, got[0].HasPrefix, "a hook NAT line this library wrote must report HasPrefix")
|
||||||
|
|
||||||
|
changed, err = h.editNAT(r, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed, "removal must drop %+v", *r)
|
||||||
|
got, err = h.getNATRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, got, "the rule must be gone after removal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A family-agnostic NAT rule fans out into an iptables and an ip6tables line
|
||||||
|
// with IPv6 on, narrows to IPv4 with it off, and a removal sweeps both families
|
||||||
|
// either way so a stale v6 line does not survive an IPv6 switch-off.
|
||||||
|
func TestHookScriptNATFamilyFanOut(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &hookScript{
|
||||||
|
rulePrefix: "go_firewall",
|
||||||
|
hookPath: filepath.Join(dir, "csfpre.sh"),
|
||||||
|
hookPerm: 0700,
|
||||||
|
ipv6Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
masq := &NATRule{Kind: Masquerade, Interface: "eth1"}
|
||||||
|
_, err := h.editNAT(masq, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
data, err := os.ReadFile(h.hookPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(data), "iptables -t nat -A POSTROUTING")
|
||||||
|
require.Contains(t, string(data), "ip6tables -t nat -A POSTROUTING")
|
||||||
|
|
||||||
|
// One family-agnostic removal clears both lines.
|
||||||
|
changed, err := h.editNAT(masq, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
got, err := h.getNATRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, got)
|
||||||
|
|
||||||
|
// With IPv6 off the write narrows to IPv4 only.
|
||||||
|
h.ipv6Enabled = false
|
||||||
|
_, err = h.editNAT(masq, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
data, err = os.ReadFile(h.hookPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(data), "iptables -t nat -A POSTROUTING")
|
||||||
|
require.NotContains(t, string(data), "ip6tables -t nat",
|
||||||
|
"a family-agnostic write must narrow to IPv4 while IPv6 is off")
|
||||||
|
|
||||||
|
// A stale v6 line (written while IPv6 was on, or by hand) is still swept.
|
||||||
|
v6line, err := h.natLine(masq, IPv6)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.WriteFile(h.hookPath, []byte("#!/bin/sh\n"+v6line+"\n"), 0700))
|
||||||
|
changed, err = h.editNAT(masq, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed, "removal must sweep the stale IPv6 line even with IPv6 off")
|
||||||
|
got, err = h.getNATRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NAT lines share the hook with filter rules, ipset commands and user shell;
|
||||||
|
// each kind must be read by its own parser only and edits must leave the others
|
||||||
|
// byte-for-byte in place. A hand-added equivalent NAT line (different comment)
|
||||||
|
// satisfies an add and is cleared by a removal, mirroring filter-rule edits.
|
||||||
|
func TestHookScriptNATCoexistsWithFilterLines(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &hookScript{
|
||||||
|
rulePrefix: "go_firewall",
|
||||||
|
hookPath: filepath.Join(dir, "csfpre.sh"),
|
||||||
|
hookPerm: 0700,
|
||||||
|
ipv6Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := &Rule{Family: IPv4, Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}
|
||||||
|
_, err := h.edit(filter, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
snat := &NATRule{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"}
|
||||||
|
_, err = h.editNAT(snat, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Each parser sees only its own lines.
|
||||||
|
frules, err := h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, frules, 1, "the NAT line must not surface as a filter rule")
|
||||||
|
nrules, err := h.getNATRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, nrules, 1, "the filter line must not surface as a NAT rule")
|
||||||
|
|
||||||
|
// Removing the NAT rule leaves the filter rule in place, and vice versa.
|
||||||
|
_, err = h.editNAT(snat, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
frules, err = h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, frules, 1, "a NAT removal must not touch filter lines")
|
||||||
|
|
||||||
|
// A hand-added equivalent line under a different comment dedups an add and is
|
||||||
|
// cleared by a removal: the comment is not part of rule identity.
|
||||||
|
foreign := &hookScript{rulePrefix: "acme", hookPath: h.hookPath, hookPerm: 0700, ipv6Enabled: true}
|
||||||
|
_, err = foreign.editNAT(snat, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
changed, err := h.editNAT(snat, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, changed, "an equivalent hand-added NAT line must satisfy the add")
|
||||||
|
changed, err = h.editNAT(snat, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed, "removal must clear the equivalent hand-added NAT line")
|
||||||
|
nrules, err = h.getNATRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, nrules)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ package firewall
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -39,3 +43,260 @@ func hookPlanter(mgr Manager) func(*Rule) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// foreignSeeder returns a function seeding a foreign rule with the backend's own
|
||||||
|
// tooling, or nil when the backend has no seeder on this platform. The seeding
|
||||||
|
// commands/paths are inherently backend-specific; the assertions in the shared
|
||||||
|
// foreignrule subtest are not.
|
||||||
|
func foreignSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||||
|
switch mgr.Type() {
|
||||||
|
case IPTablesType:
|
||||||
|
// The iptables backend manages the persistent save files (rules.v4 /
|
||||||
|
// rules.v6), so the operator-style seed is a hand-edited save-file line,
|
||||||
|
// not a live `iptables -A` (which the file model deliberately never sees).
|
||||||
|
ipt, ok := mgr.(*IPTables)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(string) (*foreignSeed, error) {
|
||||||
|
undo, err := insertSaveFileRule(ipt.IP4Path, "-A INPUT -p tcp --dport 8123 -j ACCEPT")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &foreignSeed{
|
||||||
|
rule: &Rule{Family: IPv4, Proto: TCP, Port: 8123, Action: Accept},
|
||||||
|
inScope: true,
|
||||||
|
undo: undo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
case NFTType:
|
||||||
|
return func(string) (*foreignSeed, error) {
|
||||||
|
const table = "foreignseed"
|
||||||
|
cmds := [][]string{
|
||||||
|
{"add", "table", "ip", table},
|
||||||
|
{"add", "chain", "ip", table, "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "policy", "accept", ";", "}"},
|
||||||
|
{"add", "rule", "ip", table, "input", "tcp", "dport", "8123", "accept"},
|
||||||
|
}
|
||||||
|
for _, c := range cmds {
|
||||||
|
if out, err := exec.Command("nft", c...).CombinedOutput(); err != nil {
|
||||||
|
_ = exec.Command("nft", "delete", "table", "ip", table).Run()
|
||||||
|
return nil, fmt.Errorf("nft %s: %v: %s", strings.Join(c, " "), err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &foreignSeed{
|
||||||
|
rule: &Rule{Family: IPv4, Proto: TCP, Port: 8123, Action: Accept},
|
||||||
|
inScope: false, // nft reports foreign tables but writes only to its own.
|
||||||
|
undo: func() { _ = exec.Command("nft", "delete", "table", "ip", table).Run() },
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
case UFWType:
|
||||||
|
return func(string) (*foreignSeed, error) {
|
||||||
|
if out, err := exec.Command("ufw", "allow", "8123/tcp").CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ufw: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
return &foreignSeed{
|
||||||
|
rule: &Rule{Proto: TCP, Port: 8123, Action: Accept},
|
||||||
|
inScope: true,
|
||||||
|
undo: func() { _ = exec.Command("ufw", "--force", "delete", "allow", "8123/tcp").Run() },
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
case FirewallDType:
|
||||||
|
return func(zone string) (*foreignSeed, error) {
|
||||||
|
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-port=8123/tcp").CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
return &foreignSeed{
|
||||||
|
rule: &Rule{Proto: TCP, Port: 8123, Action: Accept},
|
||||||
|
// firewalld's container is the zone itself, so every rule read from
|
||||||
|
// it — foreign included — carries the informational flag.
|
||||||
|
hasPrefix: true,
|
||||||
|
inScope: true,
|
||||||
|
undo: func() {
|
||||||
|
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-port=8123/tcp").Run()
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
case CSFType:
|
||||||
|
return func(string) (*foreignSeed, error) {
|
||||||
|
undo, err := appendConfigLine(CSFAllow, "198.51.100.99")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &foreignSeed{
|
||||||
|
rule: &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.99", Action: Accept},
|
||||||
|
inScope: true,
|
||||||
|
undo: undo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
case APFType:
|
||||||
|
return func(string) (*foreignSeed, error) {
|
||||||
|
undo, err := appendConfigLine(APFAllow, "198.51.100.99")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &foreignSeed{
|
||||||
|
rule: &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.99", Action: Accept},
|
||||||
|
inScope: true,
|
||||||
|
undo: undo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// foreignMACSeeder returns a function seeding a foreign MAC zone source with the
|
||||||
|
// backend's own tooling, or nil when the backend has no MAC-source construct on
|
||||||
|
// this platform. Only firewalld models one (a zone source) and only firewall-cmd
|
||||||
|
// can seed it; the assertions in the shared foreignmacsource subtest are not
|
||||||
|
// backend-specific.
|
||||||
|
func foreignMACSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||||
|
if mgr.Type() != FirewallDType {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(zone string) (*foreignSeed, error) {
|
||||||
|
const mac = "00:11:22:33:44:55"
|
||||||
|
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-source="+mac).CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
return &foreignSeed{
|
||||||
|
rule: &Rule{Source: mac, Action: Accept},
|
||||||
|
hasPrefix: true,
|
||||||
|
inScope: true,
|
||||||
|
undo: func() {
|
||||||
|
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-source="+mac).Run()
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// foreignProtocolSeeder returns a function seeding a foreign bare-protocol allow
|
||||||
|
// with the backend's own tooling, or nil when the backend has no distinct
|
||||||
|
// protocol-entry construct on this platform. Only firewalld stores one (a zone
|
||||||
|
// protocol entry, distinct from the rich-rule form the library writes).
|
||||||
|
func foreignProtocolSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||||
|
if mgr.Type() != FirewallDType {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(zone string) (*foreignSeed, error) {
|
||||||
|
const proto = "gre"
|
||||||
|
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-protocol="+proto).CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
return &foreignSeed{
|
||||||
|
rule: &Rule{Proto: GRE, Action: Accept},
|
||||||
|
hasPrefix: true,
|
||||||
|
inScope: true,
|
||||||
|
undo: func() {
|
||||||
|
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-protocol="+proto).Run()
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmanagedRawRuleSeeder returns a function injecting a parseable rule into a
|
||||||
|
// raw rules file the backend deliberately does not manage, or nil when the
|
||||||
|
// backend keeps no such file on this platform. Only ufw has the before/after
|
||||||
|
// split: the library writes raw rules into before.rules only, so a rule seeded
|
||||||
|
// into after.rules must stay invisible to GetRules. The returned probe is what
|
||||||
|
// the seeded line would read back as if it were (wrongly) surfaced.
|
||||||
|
func unmanagedRawRuleSeeder(mgr Manager) func() (*Rule, func(), error) {
|
||||||
|
if mgr.Type() != UFWType {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func() (*Rule, func(), error) {
|
||||||
|
const afterPath = "/etc/ufw/after.rules"
|
||||||
|
orig, err := os.ReadFile(afterPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// Inject before the real COMMIT directive. after.rules carries a "# don't
|
||||||
|
// delete the 'COMMIT' line" comment, so match the standalone COMMIT line
|
||||||
|
// rather than the first literal.
|
||||||
|
lines := strings.Split(string(orig), "\n")
|
||||||
|
placed := false
|
||||||
|
for i, l := range lines {
|
||||||
|
if strings.TrimSpace(l) == "COMMIT" {
|
||||||
|
lines = append(lines[:i:i], append([]string{"-A ufw-after-input -p tcp -m tcp --dport 8765 -j ACCEPT"}, lines[i:]...)...)
|
||||||
|
placed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !placed {
|
||||||
|
return nil, nil, fmt.Errorf("%s has no COMMIT directive to inject before", afterPath)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(afterPath, []byte(strings.Join(lines, "\n")), 0o640); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
probe := &Rule{Family: IPv4, Proto: TCP, Port: 8765, Action: Accept}
|
||||||
|
return probe, func() { _ = os.WriteFile(afterPath, orig, 0o640) }, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneInterfaceSeeder returns a function binding an interface to a named zone
|
||||||
|
// out of band (permanent config only, so nothing filters at runtime), or nil
|
||||||
|
// when the backend has no interface-to-zone mapping on this platform. Only
|
||||||
|
// firewalld models one and only firewall-cmd can seed it; the assertions in the
|
||||||
|
// shared zones subtest are not backend-specific.
|
||||||
|
func zoneInterfaceSeeder(mgr Manager) func(iface, zoneName string) (func(), error) {
|
||||||
|
if mgr.Type() != FirewallDType {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(iface, zoneName string) (func(), error) {
|
||||||
|
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zoneName, "--add-interface="+iface).CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zoneName, "--remove-interface="+iface).Run()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertSaveFileRule inserts one iptables-save rule line into path's *filter
|
||||||
|
// section, before its COMMIT, returning an undo that restores the original
|
||||||
|
// content byte for byte.
|
||||||
|
func insertSaveFileRule(path, line string) (func(), error) {
|
||||||
|
orig, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(orig), "\n")
|
||||||
|
inFilter, placed := false, false
|
||||||
|
for i, l := range lines {
|
||||||
|
trimmed := strings.TrimSpace(l)
|
||||||
|
if strings.HasPrefix(trimmed, "*") {
|
||||||
|
inFilter = trimmed == "*filter"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inFilter && trimmed == "COMMIT" {
|
||||||
|
lines = append(lines[:i:i], append([]string{line}, lines[i:]...)...)
|
||||||
|
placed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !placed {
|
||||||
|
return nil, fmt.Errorf("%s has no *filter COMMIT to insert before", path)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() { _ = os.WriteFile(path, orig, 0o600) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendConfigLine appends one line to a config file, returning an undo that
|
||||||
|
// restores the original content byte for byte.
|
||||||
|
func appendConfigLine(path, line string) (func(), error) {
|
||||||
|
orig, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
content := string(orig)
|
||||||
|
if content != "" && !strings.HasSuffix(content, "\n") {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
// The file exists, so WriteFile keeps its mode; the permission argument only
|
||||||
|
// applies on create.
|
||||||
|
if err := os.WriteFile(path, []byte(content+line+"\n"), 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() { _ = os.WriteFile(path, orig, 0o600) }, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,35 @@ package firewall
|
||||||
func hookPlanter(mgr Manager) func(*Rule) error {
|
func hookPlanter(mgr Manager) func(*Rule) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// foreignSeeder reports no out-of-band foreign-rule seeder on this platform; the
|
||||||
|
// Linux implementation in integration_linux_test.go covers the Linux backends.
|
||||||
|
// The shared suite skips its foreignrule probe on a nil result. Seeding pf (pfctl
|
||||||
|
// anchors) and wf (netsh advfirewall) the same way is still open.
|
||||||
|
func foreignSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// foreignMACSeeder reports no MAC zone-source seeder on this platform: the MAC
|
||||||
|
// source is a firewalld construct and firewalld is Linux-only.
|
||||||
|
func foreignMACSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// foreignProtocolSeeder reports no zone protocol-entry seeder on this platform,
|
||||||
|
// for the same reason as foreignMACSeeder.
|
||||||
|
func foreignProtocolSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmanagedRawRuleSeeder reports no unmanaged raw rules file on this platform:
|
||||||
|
// the before/after split is a ufw construct and ufw is Linux-only.
|
||||||
|
func unmanagedRawRuleSeeder(mgr Manager) func() (*Rule, func(), error) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneInterfaceSeeder reports no interface-to-zone seeder on this platform: the
|
||||||
|
// interface binding is a firewalld construct and firewalld is Linux-only.
|
||||||
|
func zoneInterfaceSeeder(mgr Manager) func(iface, zoneName string) (func(), error) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -47,6 +47,26 @@ type backendFactory struct {
|
||||||
new func(ctx context.Context, rulePrefix string) (Manager, error)
|
new func(ctx context.Context, rulePrefix string) (Manager, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// foreignSeed is a rule planted out of band with a backend's own tooling, standing
|
||||||
|
// in for a rule a human operator added, plus what the library should observe for
|
||||||
|
// it. Each platform's foreignSeeder produces one; see integration_linux_test.go.
|
||||||
|
type foreignSeed struct {
|
||||||
|
// rule is what GetRules should report for the seeded rule.
|
||||||
|
rule *Rule
|
||||||
|
// hasPrefix is what the informational HasPrefix flag should read: false on the
|
||||||
|
// tag-based backends (no library comment/tag on the seeded rule) and for a
|
||||||
|
// foreign nft table, true on firewalld, whose container is the zone itself, so
|
||||||
|
// every rule read from it carries the flag.
|
||||||
|
hasPrefix bool
|
||||||
|
// inScope reports whether the backend's mutations reach the seeded rule.
|
||||||
|
// Everything but a foreign nft table is in scope; nft reports foreign tables
|
||||||
|
// but scopes its writes to its own, so RemoveRule must no-op there without
|
||||||
|
// error (Sync relies on exactly that).
|
||||||
|
inScope bool
|
||||||
|
// undo unseeds, best effort — the suite's RemoveRule normally already has.
|
||||||
|
undo func()
|
||||||
|
}
|
||||||
|
|
||||||
// runIntegration constructs each backend in backends and runs the capability suite
|
// runIntegration constructs each backend in backends and runs the capability suite
|
||||||
// against it. It honors FIREWALL_BACKEND: when set, only that backend runs and a
|
// against it. It honors FIREWALL_BACKEND: when set, only that backend runs and a
|
||||||
// construction failure is fatal (the environment is expected to provide it); when
|
// construction failure is fatal (the environment is expected to provide it); when
|
||||||
|
|
@ -171,6 +191,46 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
require.True(t, matches[0].HasPrefix, "a rule this library added must report HasPrefix")
|
require.True(t, matches[0].HasPrefix, "a rule this library added must report HasPrefix")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("foreignrule", func(t *testing.T) {
|
||||||
|
// The library manages the ACTUAL firewall, not only its own rules: a rule
|
||||||
|
// created out of band with the backend's own tooling must surface in
|
||||||
|
// GetRules and be removable with RemoveRule, or Sync/Backup/Restore could
|
||||||
|
// never converge on real firewall state. Each platform provides a seeder
|
||||||
|
// that plants a rule the way an operator would (foreignSeeder); a backend
|
||||||
|
// with no seeder skips.
|
||||||
|
seeder := foreignSeeder(mgr)
|
||||||
|
if seeder == nil {
|
||||||
|
t.Skip("no out-of-band seeder for this backend")
|
||||||
|
}
|
||||||
|
seed, err := seeder(zone)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("could not seed a foreign rule: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(seed.undo)
|
||||||
|
|
||||||
|
rules := rulesOf(t, ctx, mgr, zone)
|
||||||
|
require.True(t, containsRule(rules, seed.rule, caps.Output),
|
||||||
|
"a rule seeded out of band must surface in GetRules, got %s", dumpRules(rules))
|
||||||
|
got := findRule(t, ctx, mgr, zone, seed.rule)
|
||||||
|
require.Equal(t, seed.hasPrefix, got.HasPrefix,
|
||||||
|
"HasPrefix must reflect how the backend namespaces its rules (informational only, never an ownership filter)")
|
||||||
|
|
||||||
|
if !seed.inScope {
|
||||||
|
// Reported but outside the backend's mutation scope (a foreign nft
|
||||||
|
// table): RemoveRule must no-op without error and leave the rule alone,
|
||||||
|
// or Sync would fail hard on every reconcile that sees it.
|
||||||
|
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule),
|
||||||
|
"removing a reported out-of-scope rule must no-op, not error")
|
||||||
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, caps.Output),
|
||||||
|
"an out-of-scope foreign rule must survive the no-op removal")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule),
|
||||||
|
"a foreign rule is managed like any other and must be removable")
|
||||||
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, caps.Output),
|
||||||
|
"foreign rule still present after removal")
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("output", func(t *testing.T) {
|
t.Run("output", func(t *testing.T) {
|
||||||
requireCap(t, caps.Output)
|
requireCap(t, caps.Output)
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Direction: DirOutput, Proto: TCP, Port: 8080, Action: Accept})
|
roundTripRule(t, ctx, mgr, zone, &Rule{Direction: DirOutput, Proto: TCP, Port: 8080, Action: Accept})
|
||||||
|
|
@ -196,69 +256,86 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "192.0.2.10/32", Action: Accept})
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "192.0.2.10/32", Action: Accept})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("hosthooknoport", func(t *testing.T) {
|
t.Run("hostnoport", func(t *testing.T) {
|
||||||
// csf/apf trust files store a single all-protocol address and their advanced
|
// Portless host shapes. csf/apf trust files store a single all-protocol
|
||||||
// rule holds a single port, so a portless concrete-protocol host, a
|
// address and their advanced rule holds a single port, so a portless
|
||||||
// source+destination pair, and (for apf) a multi-port list have no trust-file
|
// concrete-protocol host, a source+destination pair, and (for apf) a
|
||||||
// form. apf routes all of these to the raw-iptables hook (hostNeedsHook /
|
// multi-port list have no trust-file form there and route to the
|
||||||
// needsHook); csf routes the host shapes to the hook and expresses a
|
// raw-iptables hook (shapeNeedsHook / needsHook), while csf expresses a
|
||||||
// multi-port list natively as a comma list. Either way each must round-trip.
|
// multi-port list natively as a comma list. The chain backends express the
|
||||||
// Gated to csf/apf; the chain backends express these natively (covered
|
// same shapes natively. Every backend runs every shape so a bug in
|
||||||
// elsewhere).
|
// whichever path expresses it shows up; a backend that cannot express a
|
||||||
switch mgr.Type() {
|
// shape at all skips via the ErrUnsupported sentinel.
|
||||||
case CSFType, APFType:
|
t.Run("protohost", func(t *testing.T) {
|
||||||
default:
|
// A concrete-protocol host with no port.
|
||||||
t.Skip("only csf/apf route these shapes to the hook")
|
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Proto: TCP, Source: "192.0.2.40/32", Action: Accept})
|
||||||
}
|
})
|
||||||
// A concrete-protocol host with no port.
|
t.Run("srcdstpair", func(t *testing.T) {
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Source: "192.0.2.40/32", Action: Accept})
|
// A source+destination pair with no port.
|
||||||
// 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})
|
||||||
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
|
t.Run("multiportdeny", func(t *testing.T) {
|
||||||
// action, while csf writes it to csf.deny, whose action follows csf.conf
|
// A multi-port list as a deny: apf writes it to the hook with its literal
|
||||||
// (stock DROP). Probe both actions and round-trip the first the backend
|
// action, while csf writes it to csf.deny, whose action follows csf.conf
|
||||||
// accepts, mirroring the denyaddress case.
|
// (stock DROP). Probe both actions and round-trip the first the backend
|
||||||
roundTripVariants(t, ctx, mgr, zone,
|
// accepts, mirroring the denyaddress case.
|
||||||
&Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Reject},
|
roundTripVariantsOrSkip(t, ctx, mgr, zone,
|
||||||
&Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Drop},
|
&Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: 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("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) {
|
t.Run("matchcombos", func(t *testing.T) {
|
||||||
// A csf/apf advanced line holds one address field and one port-flow field, so a
|
// Combined matches that overflow a csf/apf advanced line — it holds exactly
|
||||||
// ported source+destination pair and a source-plus-destination port match both
|
// one address field and one port-flow field — so both route them to their
|
||||||
// overflow it. Neither backend may reject them: iptables matches -s with -d and
|
// raw-iptables hook (shapeNeedsHook); the chain backends match -s with -d
|
||||||
// --sport with --dport directly, so AddRule routes both to the raw-iptables hook
|
// and --sport with --dport directly. Every backend runs every combo; the
|
||||||
// (advRuleNeedsHook). Gated to csf/apf; the chain backends express these
|
// only capability in play is PortPair (firewalld's rich rules carry a
|
||||||
// natively and cover them elsewhere.
|
// single port element). Port 22 is avoided in every field so an SSH-driven
|
||||||
switch mgr.Type() {
|
// run keeps its session.
|
||||||
case CSFType, APFType:
|
t.Run("portedpair", func(t *testing.T) {
|
||||||
default:
|
// A source+destination pair carrying a destination port: the second address
|
||||||
t.Skip("only csf/apf route these shapes to the hook")
|
// overflows the advanced line, so on csf/apf this is the shape that used to
|
||||||
}
|
// reach MarshalAdvRule and fail. Every backend expresses it; no capability
|
||||||
// A source+destination pair carrying a destination port. hostNeedsHook stops at
|
// gates it, so the round trip is unconditional.
|
||||||
// the port, so this is the shape that used to reach MarshalAdvRule and fail.
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8443, Source: "192.0.2.50/32", Destination: "198.51.100.50/32", Action: Accept})
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8443, Source: "192.0.2.50/32", Destination: "198.51.100.50/32", Action: Accept})
|
})
|
||||||
// The same pair on a source port, and on a typed ICMP match.
|
t.Run("sourceportpair", func(t *testing.T) {
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: UDP, SourcePort: 5353, Source: "192.0.2.51/32", Destination: "198.51.100.51/32", Action: Accept})
|
// The same pair on a source port.
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.52/32", Destination: "198.51.100.52/32", Action: Accept})
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: UDP, SourcePort: 5353, Source: "192.0.2.51/32", Destination: "198.51.100.51/32", Action: Accept})
|
||||||
// A source port matched together with a destination port, with and without an
|
})
|
||||||
// address. Avoid port 22 in either field so an SSH-driven run keeps its session.
|
t.Run("icmppair", func(t *testing.T) {
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8444, SourcePort: 5354, Source: "192.0.2.53/32", Action: Accept})
|
// The same pair on a typed ICMP match.
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8445, SourcePort: 5355, 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})
|
||||||
|
})
|
||||||
|
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) {
|
t.Run("negatedaddress", func(t *testing.T) {
|
||||||
// ufw's tuple grammar cannot negate an address, so a negated source is routed
|
requireCap(t, caps.Negation)
|
||||||
// to the before.rules raw path (iptables `! -s`) rather than rejected, and
|
// A negated source must round-trip through whatever path expresses it:
|
||||||
// must round-trip through it. Gated to ufw: this exercises that specific
|
// iptables/nft/pf/firewalld negate natively, ufw's tuple grammar cannot so
|
||||||
// reroute; other backends negate (or reject) through their own paths.
|
// it reroutes to the before.rules raw path (iptables `! -s`), and csf/apf
|
||||||
if mgr.Type() != UFWType {
|
// route it to their raw-iptables hook. wf advertises Negation false (WFP
|
||||||
t.Skip("exercises ufw's negated-address reroute to before.rules")
|
// has no negated address condition) and is gated out; everyone else must
|
||||||
}
|
// express it.
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "!192.0.2.50/32", Action: Accept})
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "!192.0.2.50/32", Action: Accept})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -273,44 +350,44 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("denyactionfromconfig", func(t *testing.T) {
|
t.Run("denyexactaction", func(t *testing.T) {
|
||||||
// csf.deny / apf deny_hosts encode no action of their own: the tool applies
|
// A deny must read back with the exact action it was added with, or a
|
||||||
// its deny chain with an action taken from config — csf.conf DROP (stock
|
// caller managing it sees churn on every Sync (read back as one action,
|
||||||
// default DROP), apf conf.apf ALL_STOP (stock default DROP). A deny whose
|
// never equal to the desired other). The stakes are highest on csf/apf,
|
||||||
// action matches that is stored natively and must read back with it, not a
|
// whose deny stores encode no action of their own — the tool applies an
|
||||||
// fixed Reject — or a caller managing a Drop rule sees it churn on every Sync
|
// action taken from config (csf.conf DROP / conf.apf ALL_STOP, stock
|
||||||
// (read back as Reject, never equal to the desired Drop). A deny whose action
|
// default DROP), so a deny whose action matches config is stored natively
|
||||||
// differs has no native form but is expressible directly in iptables, so these
|
// and one that differs is injected through their pre-hook rather than
|
||||||
// backends inject it through their pre-hook rather than refusing it; it must
|
// refused — but the property holds for every backend, so both actions run
|
||||||
// round-trip with its exact action. Only the csf/apf address-list backends
|
// everywhere. A backend with no reject action at all (wf) advertises
|
||||||
// derive a deny's action from config this way.
|
// RejectAction false and skips that action by capability.
|
||||||
switch mgr.Type() {
|
for _, tc := range []struct {
|
||||||
case "csf", "apf":
|
name string
|
||||||
default:
|
deny *Rule
|
||||||
t.Skip("backend does not derive a deny's action from config")
|
}{
|
||||||
}
|
{"drop", &Rule{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.30/32", Action: Drop}},
|
||||||
// The config-matching action (Drop on a stock host) is stored natively; the
|
{"reject", &Rule{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.31/32", Action: Reject}},
|
||||||
// opposite action is injected through the hook. Both must read back exactly, so
|
|
||||||
// exercise both rather than assuming which one the host's config makes native.
|
|
||||||
for _, deny := range []*Rule{
|
|
||||||
{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.30/32", Action: Drop},
|
|
||||||
{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.31/32", Action: Reject},
|
|
||||||
} {
|
} {
|
||||||
require.NoError(t, mgr.AddRule(ctx, zone, deny),
|
deny := tc.deny
|
||||||
"a deny must be storable natively or through the hook, never refused: %+v", deny)
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, deny) })
|
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
|
// The rule must read back with its exact action so it compares equal to the
|
||||||
// desired rule and Sync leaves it in place rather than churning it.
|
// desired rule and Sync leaves it in place rather than churning it.
|
||||||
got := findRule(t, ctx, mgr, zone, deny)
|
got := findRule(t, ctx, mgr, zone, deny)
|
||||||
require.Equal(t, deny.Action, got.Action,
|
require.Equal(t, deny.Action, got.Action,
|
||||||
"a deny must read back with its exact action, not the config's default: %+v", deny)
|
"a deny must read back with its exact action, not a config default: %+v", deny)
|
||||||
require.True(t, got.Equal(deny, mgr.Capabilities().Output),
|
require.True(t, got.Equal(deny, mgr.Capabilities().Output),
|
||||||
"the read-back deny must equal the desired rule so Sync does not churn it: %+v", deny)
|
"the read-back deny must equal the desired rule so Sync does not churn it: %+v", deny)
|
||||||
|
|
||||||
require.NoError(t, mgr.RemoveRule(ctx, zone, deny))
|
require.NoError(t, mgr.RemoveRule(ctx, zone, deny))
|
||||||
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), deny, mgr.Capabilities().Output),
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), deny, mgr.Capabilities().Output),
|
||||||
"deny rule still present after removal: %+v", deny)
|
"deny rule still present after removal: %+v", deny)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -358,7 +435,11 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
// A v4 rule and its v6 twin are two rows on most backends. Removing every rule
|
// A v4 rule and its v6 twin are two rows on most backends. Removing every rule
|
||||||
// the backend reports must clear them all. Regression for a family-strict
|
// the backend reports must clear them all. Regression for a family-strict
|
||||||
// remove that could not match a family-agnostic rule at all (a silent no-op
|
// remove that could not match a family-agnostic rule at all (a silent no-op
|
||||||
// that left the port open) or that removed only one of the two rows.
|
// that left the port open) or that removed only one of the two rows. Gated
|
||||||
|
// on FamilyWithoutAddress (the probe rules are family-pinned bare ports);
|
||||||
|
// the per-family sentinel-continue below covers only the environment case
|
||||||
|
// of a backend whose own IPv6 handling is off (csf/apf).
|
||||||
|
requireCap(t, caps.FamilyWithoutAddress)
|
||||||
const port = 3492
|
const port = 3492
|
||||||
v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept}
|
v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept}
|
||||||
v6 := &Rule{Family: IPv6, Proto: TCP, Port: port, Action: Accept}
|
v6 := &Rule{Family: IPv6, Proto: TCP, Port: port, Action: Accept}
|
||||||
|
|
@ -434,12 +515,12 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
// object by the unified backends (nft inet, pf without af, firewalld's
|
// object by the unified backends (nft inet, pf without af, firewalld's
|
||||||
// dual-stack zone entries) and as one row per family by the separated backends;
|
// dual-stack zone entries) and as one row per family by the separated backends;
|
||||||
// either way GetRules reports coverage for both families. Removing a single
|
// either way GetRules reports coverage for both families. Removing a single
|
||||||
// family must leave the other in place — the backend splits the dual object.
|
// family must leave the other in place — the backend splits the dual object
|
||||||
// Where its model cannot express a single-family rule of the shape (apf's
|
// (apf splits its dual-stack port list through the raw-iptables hook). The
|
||||||
// dual-stack port lists, wf's address-less filters) it rejects the removal with
|
// split is gated on FamilyWithoutAddress: wf's address-less filters cannot
|
||||||
// ErrUnsupported instead. Regression for a concrete-family removal that dropped
|
// pin one family, so it advertises the capability false. Regression for a
|
||||||
// both families (nft/pf over-remove) or no-oped and left both (firewalld zone
|
// concrete-family removal that dropped both families (nft/pf over-remove)
|
||||||
// entries under-remove).
|
// or no-oped and left both (firewalld zone entries under-remove).
|
||||||
|
|
||||||
// splitCase is one dual-family rule shape plus a matcher over GetRules output.
|
// splitCase is one dual-family rule shape plus a matcher over GetRules output.
|
||||||
type splitCase struct {
|
type splitCase struct {
|
||||||
|
|
@ -448,8 +529,11 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSplit exercises a shape: add the FamilyAny rule, remove each family in
|
// runSplit exercises a shape: add the FamilyAny rule, remove each family in
|
||||||
// turn, and confirm the other survives (or the backend rejects the removal).
|
// turn, and confirm the other survives. A single-family removal implies
|
||||||
|
// expressing a single-family rule of the shape, so the split is gated on
|
||||||
|
// FamilyWithoutAddress (wf cannot pin a family without an address).
|
||||||
runSplit := func(t *testing.T, c splitCase) {
|
runSplit := func(t *testing.T, c splitCase) {
|
||||||
|
requireCap(t, caps.FamilyWithoutAddress)
|
||||||
coverage := func() (v4Cov, v6Cov bool) {
|
coverage := func() (v4Cov, v6Cov bool) {
|
||||||
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
||||||
if !c.match(r) {
|
if !c.match(r) {
|
||||||
|
|
@ -487,13 +571,9 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
t.Skipf("backend does not give this FamilyAny shape dual coverage (v4=%v v6=%v)", has4, has6)
|
t.Skipf("backend does not give this FamilyAny shape dual coverage (v4=%v v6=%v)", has4, has6)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove IPv4; IPv6 must survive. A backend that cannot express a
|
// Remove IPv4; IPv6 must survive. FamilyWithoutAddress is required
|
||||||
// single-family rule of this shape rejects with ErrUnsupported, so skip.
|
// above, so a single-family removal of the shape must be expressible.
|
||||||
if err := mgr.RemoveRule(ctx, zone, c.v4); errors.Is(err, ErrUnsupported) {
|
require.NoError(t, mgr.RemoveRule(ctx, zone, c.v4))
|
||||||
t.Skip("backend cannot express a single-family removal of this dual-family shape")
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
has4, has6 := coverage()
|
has4, has6 := coverage()
|
||||||
require.False(t, has4, "removing IPv4 must clear IPv4 coverage")
|
require.False(t, has4, "removing IPv4 must clear IPv4 coverage")
|
||||||
require.True(t, has6, "removing IPv4 from a FamilyAny rule must leave IPv6 in place")
|
require.True(t, has6, "removing IPv4 from a FamilyAny rule must leave IPv6 in place")
|
||||||
|
|
@ -1007,40 +1087,34 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("csfnativeicmp", func(t *testing.T) {
|
t.Run("icmptypedhost", func(t *testing.T) {
|
||||||
// csf's only native ICMP form is an advanced rule carrying exactly one address
|
// A typed ICMP match carrying a host address. The shared icmp/icmptype
|
||||||
// and a concrete type; every other shape routes to the hook (nativeICMP), so
|
// probes stop at the first form a backend accepts, which hides this one:
|
||||||
// the shared icmp/icmptype probes match csf on its bare hook form first.
|
// it is csf's only NATIVE ICMP form (a csf.allow advanced line carries
|
||||||
// Round-trip the native form here to keep the csf.allow advanced-rule path
|
// exactly one address and a concrete type; every other shape routes to its
|
||||||
// covered. Gated to csf; type 13 (Timestamp) avoids echo-request collisions.
|
// hook, see CSF.needsHook), and on apf — whose native ICMP is the address-less
|
||||||
if mgr.Type() != CSFType {
|
// IG_ICMP_TYPES list — the shape that routes to the hook (needsHook). Every
|
||||||
t.Skip("csf-specific advanced-rule ICMP path")
|
// backend runs it so both paths, and everyone else's addressed ICMP match,
|
||||||
}
|
// stay covered. Type 13 (Timestamp) avoids colliding with Windows'
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.71/32", Action: Accept})
|
// unremovable built-in echo-request rules.
|
||||||
|
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.70/32", Action: Accept})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("apficmphook", func(t *testing.T) {
|
t.Run("icmptypeddrop", func(t *testing.T) {
|
||||||
// apf carries ICMP as a zone-wide allowed-type list (IG_ICMP_TYPES), so an
|
// A typed ICMP drop with no address. A non-accept action has no place in
|
||||||
// ICMP rule with an address or a non-accept action has no native form and
|
// apf's allowed-type list, so it routes to apf's hook; the other backends
|
||||||
// routes to the hook (needsHook). The shared icmp/icmptype probes match
|
// express it directly or skip via the ErrUnsupported sentinel.
|
||||||
// apf on its native address-less accept form first, so exercise the hook path
|
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Drop})
|
||||||
// here. Gated to apf; type 13 (Timestamp) avoids echo-request collisions.
|
|
||||||
if mgr.Type() != APFType {
|
|
||||||
t.Skip("apf-specific ICMP hook routing")
|
|
||||||
}
|
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.70/32", Action: Accept})
|
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Drop})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("apfsinglefamilyport", func(t *testing.T) {
|
t.Run("singlefamilyport", func(t *testing.T) {
|
||||||
// apf's CPORTS lists are dual-stack, so a single-family bare port accept has
|
requireCap(t, caps.FamilyWithoutAddress)
|
||||||
// no native form and is written per-family through the hook
|
// A single-family bare port accept. Most backends store it directly; apf's
|
||||||
// (dualStackPortNeedsHook) rather than rejected. It must round-trip on its
|
// CPORTS port lists are dual-stack, so it has no native form there and is
|
||||||
// own; the FamilyAny→single-family split is exercised by the destport split
|
// written per-family through the hook (dualStackPortNeedsHook) rather than
|
||||||
// test. Gated to apf.
|
// rejected. wf advertises FamilyWithoutAddress false (a WFP filter scopes
|
||||||
if mgr.Type() != APFType {
|
// family through its address conditions) and is gated out. The
|
||||||
t.Skip("apf-specific single-family CPORTS-to-hook routing")
|
// FamilyAny→single-family split is exercised by the destport split test.
|
||||||
}
|
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: TCP, Port: 8090, Action: Accept})
|
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: TCP, Port: 8090, Action: Accept})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1106,29 +1180,29 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("apfnativeconnlimit", func(t *testing.T) {
|
t.Run("connlimitpersource", func(t *testing.T) {
|
||||||
// apf writes a native connection limit — a single inbound tcp/udp port, no
|
requireCap(t, caps.ConnLimit)
|
||||||
// address, per-source, rejecting the excess — to conf.apf's CLIMIT lists
|
// A per-source connection cap on a single address-less inbound tcp port,
|
||||||
// rather than the hook (isConnLimitRule). apf now also routes every other
|
// rejecting the excess. The shared connlimit probe stops at the first form
|
||||||
// connection-limit shape to the hook (needsHook), so the shared
|
// a backend accepts, which hides this one wherever a global form matched
|
||||||
// connlimit probe matches apf on its global hook form first; round-trip the
|
// first: on csf and apf it is the single NATIVE connection-limit shape
|
||||||
// native form here to keep the conf.apf CLIMIT path covered. Gated to apf.
|
// (csf.conf CONNLIMIT / conf.apf CLIMIT) while every other shape routes to
|
||||||
if mgr.Type() != APFType {
|
// their hook, and nft counts it in a named meter set. Every
|
||||||
t.Skip("apf-specific conf.apf CLIMIT path")
|
// connlimit-capable backend runs it; pf alone skips via the ErrUnsupported
|
||||||
}
|
// sentinel (its max-src-conn applies only to a pass rule, so a rejecting
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
|
// per-source cap has no pf form).
|
||||||
|
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("csfdenyremovedanyaction", func(t *testing.T) {
|
t.Run("denyremovedanyaction", func(t *testing.T) {
|
||||||
// A csf.deny entry carries no action of its own — csf applies csf.conf's action
|
requireCap(t, caps.DenyActionFromConfig)
|
||||||
// by direction — so the deny of an address is a single entry there and must be
|
// On a backend whose native deny store carries no per-entry action (the
|
||||||
// removable whatever action the caller names, as apf's deny_hosts entry is.
|
// tool applies its config's action — see DenyActionFromConfig), the deny of
|
||||||
// Otherwise RemoveRule reports success while csf keeps enforcing the entry.
|
// an address is a single entry (csf.deny, apf deny_hosts.rules) and must be
|
||||||
// Gated to csf; the stock csf.conf DROP is "DROP", so Drop is the native inbound
|
// removable whatever action the caller names. Otherwise RemoveRule reports
|
||||||
// deny action and Reject is the differing one.
|
// success while the tool keeps enforcing the entry. The stock config action
|
||||||
if mgr.Type() != CSFType {
|
// is DROP on both, so Drop is the native deny action and Reject the
|
||||||
t.Skip("csf-specific csf.deny action-agnostic removal")
|
// differing one.
|
||||||
}
|
|
||||||
host := "192.0.2.72/32"
|
host := "192.0.2.72/32"
|
||||||
added := &Rule{Family: IPv4, Proto: TCP, Port: 8084, Source: host, Action: Drop}
|
added := &Rule{Family: IPv4, Proto: TCP, Port: 8084, Source: host, Action: Drop}
|
||||||
require.NoError(t, mgr.AddRule(ctx, zone, added))
|
require.NoError(t, mgr.AddRule(ctx, zone, added))
|
||||||
|
|
@ -1142,23 +1216,10 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
require.NoError(t, mgr.RemoveRule(ctx, zone, &differing))
|
require.NoError(t, mgr.RemoveRule(ctx, zone, &differing))
|
||||||
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
||||||
require.Falsef(t, addrEqual(r.Source, host) && r.Port == 8084,
|
require.Falsef(t, addrEqual(r.Source, host) && r.Port == 8084,
|
||||||
"the csf.deny entry must be removed whatever action the target names: %+v", r)
|
"the deny entry must be removed whatever action the target names: %+v", r)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("csfnativeconnlimit", func(t *testing.T) {
|
|
||||||
// csf writes a native connection limit — a single inbound tcp port, no address,
|
|
||||||
// per-source, rejecting the excess — to csf.conf's CONNLIMIT list rather than
|
|
||||||
// the hook (isConnLimitRule). Every other connection-limit shape now routes to
|
|
||||||
// the hook (needsHook), so the shared connlimit probe matches csf on its global
|
|
||||||
// hook form first; round-trip the native form here to keep the CONNLIMIT path
|
|
||||||
// covered. Gated to csf.
|
|
||||||
if mgr.Type() != CSFType {
|
|
||||||
t.Skip("csf-specific csf.conf CONNLIMIT path")
|
|
||||||
}
|
|
||||||
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8082, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("comment", func(t *testing.T) {
|
t.Run("comment", func(t *testing.T) {
|
||||||
requireCap(t, caps.Comments)
|
requireCap(t, caps.Comments)
|
||||||
// Most backends carry a comment on a bare port rule; CSF and APF can only
|
// Most backends carry a comment on a bare port rule; CSF and APF can only
|
||||||
|
|
@ -1532,6 +1593,20 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), setRule, mgr.Capabilities().Output),
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), setRule, mgr.Capabilities().Output),
|
||||||
"set-matching rule still present after removal")
|
"set-matching rule still present after removal")
|
||||||
|
|
||||||
|
// A FamilyAny set reference is accepted and pinned to the set's own family
|
||||||
|
// rather than rejected: the set is family-typed, so the rule could never
|
||||||
|
// match the other family anyway. containsRule compares family-agnostically,
|
||||||
|
// so the pinned read-back still satisfies the FamilyAny target, and the
|
||||||
|
// same target removes it.
|
||||||
|
anyRule := &Rule{Proto: TCP, Port: 4202, Source: set.Name, Action: Accept}
|
||||||
|
require.NoError(t, mgr.AddRule(ctx, zone, anyRule),
|
||||||
|
"a FamilyAny set reference must be pinned to the set's family, not rejected")
|
||||||
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), anyRule, mgr.Capabilities().Output),
|
||||||
|
"FamilyAny rule matching on set %q not found", set.Name)
|
||||||
|
require.NoError(t, mgr.RemoveRule(ctx, zone, anyRule))
|
||||||
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), anyRule, mgr.Capabilities().Output),
|
||||||
|
"FamilyAny set-matching rule still present after removal")
|
||||||
|
|
||||||
require.NoError(t, mgr.RemoveAddressSetEntry(ctx, set.Name, "192.0.2.10"))
|
require.NoError(t, mgr.RemoveAddressSetEntry(ctx, set.Name, "192.0.2.10"))
|
||||||
sets, err = mgr.GetAddressSets(ctx)
|
sets, err = mgr.GetAddressSets(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -1575,6 +1650,88 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
require.NoError(t, mgr.RemoveAddressSet(ctx, inuse.Name))
|
require.NoError(t, mgr.RemoveAddressSet(ctx, inuse.Name))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- sync -------------------------------------------------------------------
|
||||||
|
|
||||||
|
t.Run("sync", func(t *testing.T) {
|
||||||
|
// Sync reconciles the zone toward a desired set. Desired is built as the
|
||||||
|
// CURRENT state plus two new rules, so the test never strips rules the
|
||||||
|
// environment depends on (a run may arrive over SSH), and the three
|
||||||
|
// contract points run end-to-end: missing rules are added, a second Sync
|
||||||
|
// is a no-op whatever rows the backend fanned the rules into (the diff is
|
||||||
|
// the coverage relation, not row equality), and a rule outside desired —
|
||||||
|
// freshly added here, but a foreign rule is reconciled the same way — is
|
||||||
|
// removed.
|
||||||
|
r1 := &Rule{Proto: TCP, Port: 5601, Action: Accept}
|
||||||
|
r2 := &Rule{Proto: TCP, Port: 5602, Action: Accept}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = mgr.RemoveRule(ctx, zone, r1)
|
||||||
|
_ = mgr.RemoveRule(ctx, zone, r2)
|
||||||
|
})
|
||||||
|
desired := append(rulesOf(t, ctx, mgr, zone), r1, r2)
|
||||||
|
|
||||||
|
added, removed, err := Sync(ctx, mgr, zone, desired)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, added, "Sync must add exactly the two missing rules")
|
||||||
|
require.Zero(t, removed, "Sync must keep every rule desired covers")
|
||||||
|
rules := rulesOf(t, ctx, mgr, zone)
|
||||||
|
require.True(t, containsRule(rules, r1, caps.Output), "r1 missing after Sync")
|
||||||
|
require.True(t, containsRule(rules, r2, caps.Output), "r2 missing after Sync")
|
||||||
|
|
||||||
|
// Sync against its own output is a no-op, whichever rows the backend chose
|
||||||
|
// to store the rules as.
|
||||||
|
added, removed, err = Sync(ctx, mgr, zone, desired)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, added, "a second Sync must add nothing, or every run churns")
|
||||||
|
require.Zero(t, removed, "a second Sync must remove nothing, or every run churns")
|
||||||
|
|
||||||
|
// A rule desired does not cover is removed.
|
||||||
|
r3 := &Rule{Proto: TCP, Port: 5603, Action: Accept}
|
||||||
|
require.NoError(t, mgr.AddRule(ctx, zone, r3))
|
||||||
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r3) })
|
||||||
|
added, removed, err = Sync(ctx, mgr, zone, desired)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, added, "reconciling away an undesired rule must not re-add anything")
|
||||||
|
require.NotZero(t, removed, "Sync must remove the rule desired does not cover")
|
||||||
|
rules = rulesOf(t, ctx, mgr, zone)
|
||||||
|
require.False(t, containsRule(rules, r3, caps.Output), "undesired rule still present after Sync")
|
||||||
|
require.True(t, containsRule(rules, r1, caps.Output), "r1 must survive the reconcile")
|
||||||
|
require.True(t, containsRule(rules, r2, caps.Output), "r2 must survive the reconcile")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replacerules", func(t *testing.T) {
|
||||||
|
// ReplaceRulesBatch applies a full desired set in one shot — nft loads one
|
||||||
|
// script, iptables one restore file, pf its anchor — so the managed rule
|
||||||
|
// space becomes exactly the given set. Only the batching backends carry
|
||||||
|
// the method; everywhere else ReplaceRules falls back to Sync, which the
|
||||||
|
// sync subtest covers non-destructively (running this probe's small
|
||||||
|
// desired set through Sync would strip rules the environment depends on).
|
||||||
|
b, ok := mgr.(RuleBatcher)
|
||||||
|
if !ok {
|
||||||
|
t.Skip("backend has no batch replace; ReplaceRules falls back to Sync (covered by the sync subtest)")
|
||||||
|
}
|
||||||
|
r1 := &Rule{Proto: TCP, Port: 5604, Action: Accept}
|
||||||
|
r2 := &Rule{Proto: TCP, Port: 5605, Action: Accept}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = mgr.RemoveRule(ctx, zone, r1)
|
||||||
|
_ = mgr.RemoveRule(ctx, zone, r2)
|
||||||
|
})
|
||||||
|
require.NoError(t, b.ReplaceRulesBatch(ctx, zone, []*Rule{r1, r2}))
|
||||||
|
rules := rulesOf(t, ctx, mgr, zone)
|
||||||
|
require.True(t, containsRule(rules, r1, caps.Output), "r1 missing after batch replace")
|
||||||
|
require.True(t, containsRule(rules, r2, caps.Output), "r2 missing after batch replace")
|
||||||
|
|
||||||
|
// Replacing with a narrower set drops what it omits.
|
||||||
|
require.NoError(t, b.ReplaceRulesBatch(ctx, zone, []*Rule{r1}))
|
||||||
|
rules = rulesOf(t, ctx, mgr, zone)
|
||||||
|
require.True(t, containsRule(rules, r1, caps.Output), "r1 must survive the narrower replace")
|
||||||
|
require.False(t, containsRule(rules, r2, caps.Output), "r2 must be dropped by the narrower replace")
|
||||||
|
|
||||||
|
// An empty set clears the managed rules.
|
||||||
|
require.NoError(t, b.ReplaceRulesBatch(ctx, zone, nil))
|
||||||
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), r1, caps.Output),
|
||||||
|
"replacing with an empty set must clear the managed rules")
|
||||||
|
})
|
||||||
|
|
||||||
// --- backup / restore -----------------------------------------------------
|
// --- backup / restore -----------------------------------------------------
|
||||||
|
|
||||||
t.Run("backup", func(t *testing.T) {
|
t.Run("backup", func(t *testing.T) {
|
||||||
|
|
@ -1622,10 +1779,11 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
// forward order while AddRule prepends, silently reversing them — so a
|
// forward order while AddRule prepends, silently reversing them — so a
|
||||||
// specific deny backed up above a broad allow came back below it and never
|
// specific deny backed up above a broad allow came back below it and never
|
||||||
// fired. Compare each backend against itself: the order right after the adds
|
// fired. Compare each backend against itself: the order right after the adds
|
||||||
// must equal the order after a backup/remove/restore cycle.
|
// must equal the order after a backup/remove/restore cycle. Gated on
|
||||||
if !orderedFilterBackend(mgr.Type()) {
|
// RuleOrdering: a backend whose filter rules form a first-match chain
|
||||||
t.Skip("backend has no first-match chain order to preserve")
|
// advertises it, while the list/zone-model backends (csf, apf, firewalld,
|
||||||
}
|
// wf) do not order rules this way.
|
||||||
|
requireCap(t, caps.RuleOrdering)
|
||||||
ports := []uint16{4101, 4102, 4103}
|
ports := []uint16{4101, 4102, 4103}
|
||||||
var rules []*Rule
|
var rules []*Rule
|
||||||
for _, p := range ports {
|
for _, p := range ports {
|
||||||
|
|
@ -1714,139 +1872,201 @@ func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("profilescope", func(t *testing.T) {
|
t.Run("zones", func(t *testing.T) {
|
||||||
// The Windows Filtering Platform maps a zone to a firewall profile; AddRule
|
requireCap(t, caps.Zones)
|
||||||
// and GetRules scope to it, so RemoveRule must too. Removing a rule from one
|
// A zones backend maps interfaces to zones: GetZone with no interface
|
||||||
// profile must not delete an identical rule in another. Only the wf backend
|
// names the default zone, an interface bound to another zone (seeded out
|
||||||
// models per-zone profiles this way; the others share one rule space.
|
// of band, permanent config only) resolves to that zone, and an unbound
|
||||||
if mgr.Type() != "windows-firewall" {
|
// interface keeps resolving to the default. The binding targets the
|
||||||
t.Skip("backend does not scope rules to per-zone profiles")
|
// trusted zone so an accidental runtime activation cannot filter
|
||||||
|
// anything away.
|
||||||
|
def, err := mgr.GetZone(ctx, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, def, "a zones backend must name its default zone")
|
||||||
|
|
||||||
|
seeder := zoneInterfaceSeeder(mgr)
|
||||||
|
if seeder == nil {
|
||||||
|
t.Skip("no out-of-band interface-to-zone seeder for this backend")
|
||||||
}
|
}
|
||||||
|
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}
|
r := &Rule{Proto: TCP, Port: 5303, Action: Accept}
|
||||||
require.NoError(t, mgr.AddRule(ctx, "public", r))
|
candidates := []string{"public", "private", "home", "work", "domain", "internal", "dmz"}
|
||||||
require.NoError(t, mgr.AddRule(ctx, "private", r))
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = mgr.RemoveRule(ctx, "public", r)
|
|
||||||
_ = mgr.RemoveRule(ctx, "private", r)
|
|
||||||
})
|
|
||||||
|
|
||||||
pub, err := mgr.GetRules(ctx, "public")
|
var zoneA, zoneB string
|
||||||
require.NoError(t, err)
|
for i, z := range candidates {
|
||||||
require.True(t, containsRule(pub, r, mgr.Capabilities().Output), "rule should be present in the public profile")
|
if err := mgr.AddRule(ctx, z, r); err != nil {
|
||||||
priv, err := mgr.GetRules(ctx, "private")
|
continue
|
||||||
require.NoError(t, err)
|
}
|
||||||
require.True(t, containsRule(priv, r, mgr.Capabilities().Output), "rule should be present in the private profile")
|
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.True(t, containsRule(rulesOf(t, ctx, mgr, zoneA), r, caps.Output), "rule should be present in zone %q", zoneA)
|
||||||
require.NoError(t, mgr.RemoveRule(ctx, "private", r))
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneB), r, caps.Output), "rule should be present in zone %q", zoneB)
|
||||||
priv, err = mgr.GetRules(ctx, "private")
|
|
||||||
require.NoError(t, err)
|
// Remove from the second zone only; the first zone's copy must survive.
|
||||||
require.False(t, containsRule(priv, r, mgr.Capabilities().Output), "rule should be gone from the private profile")
|
require.NoError(t, mgr.RemoveRule(ctx, zoneB, r))
|
||||||
pub, err = mgr.GetRules(ctx, "public")
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zoneB), r, caps.Output), "rule should be gone from zone %q", zoneB)
|
||||||
require.NoError(t, err)
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneA), r, caps.Output),
|
||||||
require.True(t, containsRule(pub, r, mgr.Capabilities().Output), "removing from the private profile must not delete the public rule")
|
"removing the rule from zone %q must not delete the copy in zone %q", zoneB, zoneA)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("foreignmacsource", func(t *testing.T) {
|
t.Run("foreignmacsource", func(t *testing.T) {
|
||||||
// firewalld stores a MAC (or ipset) zone source, which GetRules reports as a
|
// A backend may store a MAC zone source (firewalld), which GetRules reports
|
||||||
// bare-source rule. RemoveRule's source shortcut previously only handled an
|
// as a bare-source rule. RemoveRule's source shortcut previously only handled
|
||||||
// IP/CIDR, so a foreign MAC source could never be removed — Sync/Restore could
|
// an IP/CIDR, so a foreign MAC source could never be removed — Sync/Restore
|
||||||
// not converge on it. Seed one out of band in the permanent config the backend
|
// could not converge on it. Seed one out of band in the permanent config the
|
||||||
// reads and confirm the library both surfaces and removes it. Only firewalld
|
// backend reads and confirm the library both surfaces and removes it. The
|
||||||
// models MAC/ipset zone sources this way.
|
// generic foreign-rule sweep is the foreignrule subtest; the seeding
|
||||||
if mgr.Type() != "firewalld" {
|
// commands are inherently backend-specific (foreignMACSeeder), so a backend
|
||||||
t.Skip("backend does not model MAC/ipset zone sources")
|
// without a seeder skips, exactly like foreignrule.
|
||||||
|
seeder := foreignMACSeeder(mgr)
|
||||||
|
if seeder == nil {
|
||||||
|
t.Skip("no out-of-band MAC zone-source seeder for this backend")
|
||||||
}
|
}
|
||||||
const mac = "00:11:22:33:44:55"
|
seed, err := seeder(zone)
|
||||||
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-source="+mac).CombinedOutput(); err != nil {
|
if err != nil {
|
||||||
t.Skipf("could not seed a foreign MAC source (firewall-cmd: %v): %s", err, out)
|
t.Skipf("could not seed a foreign MAC source: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(seed.undo)
|
||||||
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-source="+mac).Run()
|
|
||||||
})
|
|
||||||
|
|
||||||
want := &Rule{Source: mac, Action: Accept}
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
|
||||||
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
|
|
||||||
"a foreign MAC zone source should surface in GetRules")
|
"a foreign MAC zone source should surface in GetRules")
|
||||||
|
|
||||||
require.NoError(t, mgr.RemoveRule(ctx, zone, want))
|
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule))
|
||||||
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
|
||||||
"RemoveRule must remove a foreign MAC zone source")
|
"RemoveRule must remove a foreign MAC zone source")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("foreignprotocol", func(t *testing.T) {
|
t.Run("foreignprotocol", func(t *testing.T) {
|
||||||
// firewalld stores a bare-protocol allow (firewall-cmd --add-protocol) as a
|
// A backend may store a bare-protocol allow as its own construct distinct
|
||||||
// zone protocol entry, distinct from the rich-rule form the library writes.
|
// from the rule form the library writes (firewalld's zone protocol entries,
|
||||||
// GetRules must surface it and RemoveRule must remove it, or a foreign
|
// seeded with firewall-cmd --add-protocol). GetRules must surface it and
|
||||||
// --add-protocol is invisible to Sync/Restore. Only firewalld models this.
|
// RemoveRule must remove it, or a foreign protocol allow is invisible to
|
||||||
if mgr.Type() != "firewalld" {
|
// Sync/Restore. Like foreignmacsource, the seeding is backend-specific
|
||||||
t.Skip("backend does not model zone protocol entries")
|
// (foreignProtocolSeeder) and a backend without a seeder skips.
|
||||||
|
seeder := foreignProtocolSeeder(mgr)
|
||||||
|
if seeder == nil {
|
||||||
|
t.Skip("no out-of-band protocol-entry seeder for this backend")
|
||||||
}
|
}
|
||||||
const proto = "gre"
|
seed, err := seeder(zone)
|
||||||
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-protocol="+proto).CombinedOutput(); err != nil {
|
if err != nil {
|
||||||
t.Skipf("could not seed a foreign protocol (firewall-cmd: %v): %s", err, out)
|
t.Skipf("could not seed a foreign protocol: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(seed.undo)
|
||||||
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-protocol="+proto).Run()
|
|
||||||
})
|
|
||||||
|
|
||||||
want := &Rule{Proto: GRE, Action: Accept}
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
|
||||||
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
|
|
||||||
"a foreign zone protocol should surface in GetRules")
|
"a foreign zone protocol should surface in GetRules")
|
||||||
|
|
||||||
require.NoError(t, mgr.RemoveRule(ctx, zone, want))
|
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule))
|
||||||
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
|
||||||
"RemoveRule must remove a foreign zone protocol")
|
"RemoveRule must remove a foreign zone protocol")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("afterrulesexcluded", func(t *testing.T) {
|
t.Run("afterrulesexcluded", func(t *testing.T) {
|
||||||
// ufw writes and removes raw rules only in before.rules, so GetRules must not
|
// A backend may keep a raw rules file it deliberately does not manage (ufw's
|
||||||
// surface a rule from after.rules — otherwise Backup captures it and Restore
|
// after.rules; the library writes raw rules only into before.rules). GetRules
|
||||||
// re-adds it into before.rules, duplicating it. Only the ufw backend has this
|
// must not surface a rule from it — otherwise Backup captures it and Restore
|
||||||
// before/after split.
|
// re-adds it into the managed file, duplicating it. The seeding edits the
|
||||||
if mgr.Type() != "ufw" {
|
// backend's own file (unmanagedRawRuleSeeder), so a backend without one skips.
|
||||||
t.Skip("backend has no after.rules file")
|
seeder := unmanagedRawRuleSeeder(mgr)
|
||||||
|
if seeder == nil {
|
||||||
|
t.Skip("backend has no unmanaged raw rules file to seed")
|
||||||
}
|
}
|
||||||
const afterPath = "/etc/ufw/after.rules"
|
probe, undo, err := seeder()
|
||||||
orig, err := os.ReadFile(afterPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("cannot read after.rules: %v", err)
|
t.Skipf("could not seed the unmanaged raw rule: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = os.WriteFile(afterPath, orig, 0o640) })
|
t.Cleanup(undo)
|
||||||
|
|
||||||
// Inject a parseable rule into the ufw-after-input chain, before the real
|
|
||||||
// COMMIT directive. after.rules carries a "# don't delete the 'COMMIT' line"
|
|
||||||
// comment, so match the standalone COMMIT line rather than the first literal.
|
|
||||||
lines := strings.Split(string(orig), "\n")
|
|
||||||
placed := false
|
|
||||||
for i, l := range lines {
|
|
||||||
if strings.TrimSpace(l) == "COMMIT" {
|
|
||||||
lines = append(lines[:i:i], append([]string{"-A ufw-after-input -p tcp -m tcp --dport 8765 -j ACCEPT"}, lines[i:]...)...)
|
|
||||||
placed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.True(t, placed, "after.rules should have a COMMIT directive to inject before")
|
|
||||||
require.NoError(t, os.WriteFile(afterPath, []byte(strings.Join(lines, "\n")), 0o640))
|
|
||||||
|
|
||||||
probe := &Rule{Family: IPv4, Proto: TCP, Port: 8765, Action: Accept}
|
|
||||||
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), probe, mgr.Capabilities().Output),
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), probe, mgr.Capabilities().Output),
|
||||||
"a rule in after.rules must not be surfaced by GetRules")
|
"a rule in an unmanaged raw rules file must not be surfaced by GetRules")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// orderedFilterBackend reports whether a backend's filter rules form a first-match
|
|
||||||
// chain whose order Restore must reproduce. The list/zone-model backends (csf, apf,
|
|
||||||
// firewalld, wf) do not order rules this way, so the restoreorder check is skipped
|
|
||||||
// for them.
|
|
||||||
func orderedFilterBackend(typ string) bool {
|
|
||||||
switch typ {
|
|
||||||
case "nftables", "iptables", "ufw", "pf":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers ----------------------------------------------------------------
|
// --- helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
// requireCap skips the current subtest when the backend does not advertise the
|
// requireCap skips the current subtest when the backend does not advertise the
|
||||||
|
|
@ -1863,6 +2083,28 @@ func requireCap(t *testing.T, supported bool) {
|
||||||
func roundTripRule(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
|
func roundTripRule(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
require.NoError(t, mgr.AddRule(ctx, zone, rule))
|
require.NoError(t, mgr.AddRule(ctx, zone, rule))
|
||||||
|
roundTripAdded(t, ctx, mgr, zone, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTripRuleOrSkip is roundTripRule for a shape not every backend can express:
|
||||||
|
// a rejection with the ErrUnsupported sentinel skips the subtest instead of
|
||||||
|
// failing it. A skip marks a real expressiveness gap — a feature the backend
|
||||||
|
// might expose (or a capability that should advertise it) — while any other
|
||||||
|
// failure is still a bug.
|
||||||
|
func roundTripRuleOrSkip(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
|
||||||
|
t.Helper()
|
||||||
|
err := mgr.AddRule(ctx, zone, rule)
|
||||||
|
if errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Skipf("backend cannot express this shape: %v", err)
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
roundTripAdded(t, ctx, mgr, zone, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTripAdded finishes a round trip for a rule AddRule already accepted: it
|
||||||
|
// must read back, remove, and read back as gone.
|
||||||
|
func roundTripAdded(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
|
||||||
|
t.Helper()
|
||||||
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
|
||||||
|
|
||||||
rules := rulesOf(t, ctx, mgr, zone)
|
rules := rulesOf(t, ctx, mgr, zone)
|
||||||
|
|
@ -1880,6 +2122,26 @@ func roundTripRule(t *testing.T, ctx context.Context, mgr Manager, zone string,
|
||||||
// requires a type. It fails if the backend rejects every form as unsupported,
|
// requires a type. It fails if the backend rejects every form as unsupported,
|
||||||
// since the capability under test claimed the feature works.
|
// since the capability under test claimed the feature works.
|
||||||
func roundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) {
|
func roundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) {
|
||||||
|
t.Helper()
|
||||||
|
if !tryRoundTripVariants(t, ctx, mgr, zone, variants...) {
|
||||||
|
t.Fatal("backend advertised the capability but rejected every probe form as unsupported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTripVariantsOrSkip is roundTripVariants for a shape family no capability
|
||||||
|
// guards: a backend that rejects every form with the ErrUnsupported sentinel
|
||||||
|
// skips the subtest instead of failing it.
|
||||||
|
func roundTripVariantsOrSkip(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) {
|
||||||
|
t.Helper()
|
||||||
|
if !tryRoundTripVariants(t, ctx, mgr, zone, variants...) {
|
||||||
|
t.Skip("backend cannot express this shape in any probed form")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryRoundTripVariants round-trips the first variant the backend accepts and
|
||||||
|
// reports whether any was accepted. A variant rejected with an ErrUnsupported
|
||||||
|
// sentinel moves on to the next; any other add error fails the test.
|
||||||
|
func tryRoundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) bool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, rule := range variants {
|
for _, rule := range variants {
|
||||||
err := mgr.AddRule(ctx, zone, rule)
|
err := mgr.AddRule(ctx, zone, rule)
|
||||||
|
|
@ -1887,15 +2149,10 @@ func roundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone stri
|
||||||
continue // the backend cannot express this form; try the next.
|
continue // the backend cannot express this form; try the next.
|
||||||
}
|
}
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
|
roundTripAdded(t, ctx, mgr, zone, rule)
|
||||||
|
return true
|
||||||
rules := rulesOf(t, ctx, mgr, zone)
|
|
||||||
require.True(t, containsRule(rules, rule, mgr.Capabilities().Output), "added rule %+v not found in %s", rule, dumpRules(rules))
|
|
||||||
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
|
|
||||||
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output), "rule still present after removal")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
t.Fatal("backend advertised the capability but rejected every probe form as unsupported")
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// roundTripNATVariants tries each NAT rule form and round-trips the first the
|
// roundTripNATVariants tries each NAT rule form and round-trips the first the
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ func probeDebianLayout(root string) (l iptLayout, ok bool) {
|
||||||
|
|
||||||
// probeRHELLayout reports the RHEL/iptables-services layout
|
// probeRHELLayout reports the RHEL/iptables-services layout
|
||||||
// (/etc/sysconfig/iptables and ip6tables, restored by the iptables and
|
// (/etc/sysconfig/iptables and ip6tables, restored by the iptables and
|
||||||
// ip6tables services) if its save files are both present under root. It does
|
// ip6tables services) if its save files are both present under root. The
|
||||||
// caller must have already confirmed the RHEL v4 path exists before treating
|
// caller must have already confirmed the RHEL v4 path exists before treating
|
||||||
// an incomplete pair (ok=false) as fatal rather than falling through to
|
// an incomplete pair (ok=false) as fatal rather than falling through to
|
||||||
// another layout. root is prepended to both paths so tests can point the
|
// another layout. root is prepended to both paths so tests can point the
|
||||||
|
|
@ -111,9 +111,9 @@ func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) {
|
||||||
|
|
||||||
// Prefer the RHEL layout when its v4 save file is present. A v4 file with
|
// Prefer the RHEL layout when its v4 save file is present. A v4 file with
|
||||||
// no v6 partner is still a fatal IPTablesNoSave (not a signal to try the
|
// no v6 partner is still a fatal IPTablesNoSave (not a signal to try the
|
||||||
// Debian layout) — we do not know what may be used for the firewall in that
|
// Debian layout), since what manages the firewall in that case is unknown.
|
||||||
// case. Only when the RHEL v4 path is entirely absent do we probe the
|
// Only when the RHEL v4 path is entirely absent is the Debian
|
||||||
// Debian iptables-persistent layout instead.
|
// iptables-persistent layout probed instead.
|
||||||
var layout iptLayout
|
var layout iptLayout
|
||||||
if _, err := os.Stat("/etc/sysconfig/iptables"); err == nil {
|
if _, err := os.Stat("/etc/sysconfig/iptables"); err == nil {
|
||||||
l, ok := probeRHELLayout("")
|
l, ok := probeRHELLayout("")
|
||||||
|
|
@ -137,8 +137,8 @@ func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) {
|
||||||
return nil, errors.New(IPTablesNoService)
|
return nil, errors.New(IPTablesNoService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If ip6tables service is missing, we do not want to modify iptables
|
// A missing ip6tables service means something unknown manages the v6
|
||||||
// as we do not know what may be used for the firewall. Skip the redundant
|
// firewall, so iptables must not be modified either. Skip the redundant
|
||||||
// check when it is the same service already confirmed enabled above (the
|
// check when it is the same service already confirmed enabled above (the
|
||||||
// Debian layout uses one service for both families).
|
// Debian layout uses one service for both families).
|
||||||
ipt.IP6Service = layout.ip6Service
|
ipt.IP6Service = layout.ip6Service
|
||||||
|
|
@ -164,21 +164,25 @@ func (f *IPTables) Type() string {
|
||||||
// Capabilities returns the set of features this backend can express.
|
// Capabilities returns the set of features this backend can express.
|
||||||
func (f *IPTables) Capabilities() Capabilities {
|
func (f *IPTables) Capabilities() Capabilities {
|
||||||
return Capabilities{
|
return Capabilities{
|
||||||
Output: true,
|
Output: true,
|
||||||
Forward: true,
|
Forward: true,
|
||||||
ICMPv6: true,
|
ICMPv6: true,
|
||||||
PortList: true,
|
PortList: true,
|
||||||
ConnState: true,
|
PortPair: true,
|
||||||
InterfaceMatch: true,
|
ConnState: true,
|
||||||
Logging: true,
|
InterfaceMatch: true,
|
||||||
RateLimit: true,
|
Logging: true,
|
||||||
ConnLimit: true,
|
RateLimit: true,
|
||||||
NAT: true,
|
ConnLimit: true,
|
||||||
RuleOrdering: true,
|
NAT: true,
|
||||||
DefaultPolicy: true,
|
RuleOrdering: true,
|
||||||
RuleCounters: true,
|
DefaultPolicy: true,
|
||||||
AddressSets: true,
|
RuleCounters: true,
|
||||||
Comments: 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 so the negation is not cleared before the match token is read.
|
||||||
continue
|
continue
|
||||||
case "-p", "--protocol":
|
case "-p", "--protocol":
|
||||||
// We do not support negation on this parameter.
|
// Negation is unsupported on this parameter.
|
||||||
if not {
|
if not {
|
||||||
return nil, fmt.Errorf("negation is defined for protocol, which our limited rule structure does not support")
|
return nil, fmt.Errorf("negation is defined for protocol, which our limited rule structure does not support")
|
||||||
}
|
}
|
||||||
|
|
@ -328,7 +332,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
||||||
return nil, fmt.Errorf("invalid source parameter")
|
return nil, fmt.Errorf("invalid source parameter")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm we can parse the address.
|
// Confirm the address parses.
|
||||||
_, _, err := net.ParseCIDR(tokens[i])
|
_, _, err := net.ParseCIDR(tokens[i])
|
||||||
ip := net.ParseIP(tokens[i])
|
ip := net.ParseIP(tokens[i])
|
||||||
if err != nil && ip == nil {
|
if err != nil && ip == nil {
|
||||||
|
|
@ -347,7 +351,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
||||||
return nil, fmt.Errorf("invalid destination parameter")
|
return nil, fmt.Errorf("invalid destination parameter")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm we can parse the address.
|
// Confirm the address parses.
|
||||||
_, _, err := net.ParseCIDR(tokens[i])
|
_, _, err := net.ParseCIDR(tokens[i])
|
||||||
ip := net.ParseIP(tokens[i])
|
ip := net.ParseIP(tokens[i])
|
||||||
if err != nil && ip == nil {
|
if err != nil && ip == nil {
|
||||||
|
|
@ -434,7 +438,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
||||||
}
|
}
|
||||||
r.OutInterface = tokens[i]
|
r.OutInterface = tokens[i]
|
||||||
case "-j", "--jump":
|
case "-j", "--jump":
|
||||||
// We do not support negation on this parameter.
|
// Negation is unsupported on this parameter.
|
||||||
if not {
|
if not {
|
||||||
return nil, fmt.Errorf("negation is defined for jump, which our limited rule structure does not support")
|
return nil, fmt.Errorf("negation is defined for jump, which our limited rule structure does not support")
|
||||||
}
|
}
|
||||||
|
|
@ -450,6 +454,11 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
||||||
r.Action = Drop
|
r.Action = Drop
|
||||||
case "REJECT":
|
case "REJECT":
|
||||||
r.Action = Reject
|
r.Action = Reject
|
||||||
|
// The Rule model carries no reject type, so a `--reject-with` detail is
|
||||||
|
// consumed and a rewrite re-emits a plain REJECT (iptables' default
|
||||||
|
// icmp-port-unreachable). Rejecting the line instead would leave common
|
||||||
|
// stock rules (RHEL's icmp-host-prohibited REJECT) foreign and
|
||||||
|
// unmanageable, which costs more than the reject-code drift.
|
||||||
if i+2 < len(tokens) {
|
if i+2 < len(tokens) {
|
||||||
if tokens[i+1] == "--reject-with" {
|
if tokens[i+1] == "--reject-with" {
|
||||||
i += 2
|
i += 2
|
||||||
|
|
@ -476,7 +485,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
||||||
return nil, fmt.Errorf("unsupported jump option: %s", tokens[i])
|
return nil, fmt.Errorf("unsupported jump option: %s", tokens[i])
|
||||||
}
|
}
|
||||||
case "-m", "--match":
|
case "-m", "--match":
|
||||||
// We do not support negation on this parameter (the set match negates
|
// Negation is unsupported on this parameter (the set match negates
|
||||||
// internally, after `-m set`, so it is handled inside its case).
|
// internally, after `-m set`, so it is handled inside its case).
|
||||||
if not {
|
if not {
|
||||||
return nil, fmt.Errorf("negation is defined for match, which our limited rule structure does not support")
|
return nil, fmt.Errorf("negation is defined for match, which our limited rule structure does not support")
|
||||||
|
|
@ -627,12 +636,15 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
||||||
r.ICMPType = Ptr(n)
|
r.ICMPType = Ptr(n)
|
||||||
}
|
}
|
||||||
case "multiport":
|
case "multiport":
|
||||||
// -m multiport --dports/--sports 80,443,1000:2000 (also --ports).
|
// -m multiport --dports/--sports 80,443,1000:2000. `--ports`/`--port`
|
||||||
|
// means source OR destination port, which the model cannot hold —
|
||||||
|
// mapping it onto one side would silently drop the other half on a
|
||||||
|
// re-marshal — so such a line stays foreign.
|
||||||
if i+2 >= len(tokens) {
|
if i+2 >= len(tokens) {
|
||||||
return nil, fmt.Errorf("invalid multiport match")
|
return nil, fmt.Errorf("invalid multiport match")
|
||||||
}
|
}
|
||||||
switch tokens[i+1] {
|
switch tokens[i+1] {
|
||||||
case "--dports", "--dport", "--sports", "--sport", "--ports", "--port":
|
case "--dports", "--dport", "--sports", "--sport":
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
|
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
|
||||||
}
|
}
|
||||||
|
|
@ -656,7 +668,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "tcp":
|
case "tcp":
|
||||||
// Invalid protocol define.
|
// Reject an unknown protocol token.
|
||||||
if r.Proto == UDP {
|
if r.Proto == UDP {
|
||||||
return nil, fmt.Errorf("specifying TCP options for UDP")
|
return nil, fmt.Errorf("specifying TCP options for UDP")
|
||||||
}
|
}
|
||||||
|
|
@ -713,7 +725,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
||||||
case "udp", "sctp":
|
case "udp", "sctp":
|
||||||
// SCTP carries ports like UDP and iptables-save spells its port
|
// SCTP carries ports like UDP and iptables-save spells its port
|
||||||
// match module `-m sctp`, so it shares this branch.
|
// match module `-m sctp`, so it shares this branch.
|
||||||
// Invalid protocol define.
|
// Reject an unknown protocol token.
|
||||||
if r.Proto == TCP {
|
if r.Proto == TCP {
|
||||||
return nil, fmt.Errorf("specifying UDP options for TCP")
|
return nil, fmt.Errorf("specifying UDP options for TCP")
|
||||||
}
|
}
|
||||||
|
|
@ -775,7 +787,7 @@ func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error)
|
||||||
return nil, fmt.Errorf("unsupported option: %s", tokens[i])
|
return nil, fmt.Errorf("unsupported option: %s", tokens[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
// As rule has been parsed, we may now mark not as false.
|
// The token consumed any pending negation; clear it for the next one.
|
||||||
not = false
|
not = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -861,7 +873,11 @@ func coalesceLoggedRules(rules []*Rule) []*Rule {
|
||||||
|
|
||||||
// parseFilterFile reads a family's iptables-save file and returns its filter
|
// parseFilterFile reads a family's iptables-save file and returns its filter
|
||||||
// rules as logical rules, coalescing each LOG line with the action line that
|
// rules as logical rules, coalescing each LOG line with the action line that
|
||||||
// follows it. Lines that do not parse (nat rules, custom chains) are skipped.
|
// follows it. Lines that do not parse (nat rules, custom chains, unmodeled
|
||||||
|
// foreign matches) are skipped, but an unmodeled line still holds its physical
|
||||||
|
// slot during coalescing: a LOG line and an action line separated by a foreign
|
||||||
|
// line are NOT one logged rule — extractRuleLines and logicalStarts see them
|
||||||
|
// apart, so pairing them here would report a rule no removal could ever find.
|
||||||
func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) {
|
func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) {
|
||||||
fd, err := os.Open(path)
|
fd, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -895,6 +911,10 @@ func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error)
|
||||||
}
|
}
|
||||||
r, err := f.UnmarshalRule(line, family)
|
r, err := f.UnmarshalRule(line, family)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Keep the unmodeled line's slot so LOG pairing cannot reach across it.
|
||||||
|
if strings.HasPrefix(f.ruleLineBody(line), "-A ") {
|
||||||
|
perLine = append(perLine, nil)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// UnmarshalRule already stripped the prefix from the comment and
|
// UnmarshalRule already stripped the prefix from the comment and
|
||||||
|
|
@ -905,9 +925,23 @@ func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error)
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Return the coalesced rules; GetRules assigns Number once both save files are
|
// Coalesce LOG pairs positionally — exactly as logicalStarts numbers them —
|
||||||
// read (the other callers use the result only for dedup and never read Number).
|
// then compact the unmodeled slots away; GetRules assigns Number once both
|
||||||
return coalesceLoggedRules(perLine), nil
|
// save files are read (the other callers use the result only for dedup and
|
||||||
|
// never read Number).
|
||||||
|
starts := f.logicalStarts(perLine)
|
||||||
|
out := make([]*Rule, 0, len(perLine))
|
||||||
|
for i, r := range perLine {
|
||||||
|
if r == nil || starts[i] == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Action == ActionInvalid && r.Log {
|
||||||
|
out = append(out, mergeLogPair(r, perLine[i+1]))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRules returns the existing filter rules from the zone.
|
// GetRules returns the existing filter rules from the zone.
|
||||||
|
|
@ -931,11 +965,62 @@ func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule
|
||||||
numberByDirection(v4)
|
numberByDirection(v4)
|
||||||
numberByDirection(v6)
|
numberByDirection(v6)
|
||||||
|
|
||||||
|
// The save files carry no packet/byte counters — the kernel does — so merge
|
||||||
|
// them from the live ruleset (RuleCounters).
|
||||||
|
f.mergeLiveCounters(ctx, v4, IPv4)
|
||||||
|
f.mergeLiveCounters(ctx, v6, IPv6)
|
||||||
|
|
||||||
rules = append(rules, v4...)
|
rules = append(rules, v4...)
|
||||||
rules = append(rules, v6...)
|
rules = append(rules, v6...)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeLiveCounters copies the kernel's packet/byte counters onto the
|
||||||
|
// file-parsed rules, matching `iptables-save -c` lines to file rules by rule
|
||||||
|
// identity. It is best-effort: a missing save binary, a file edit not yet
|
||||||
|
// activated by Reload, or a rule absent from the kernel simply leaves that
|
||||||
|
// rule's counters zero. Counters are informational (RuleCounters) and never
|
||||||
|
// part of rule identity.
|
||||||
|
func (f *IPTables) mergeLiveCounters(ctx context.Context, rules []*Rule, fam Family) {
|
||||||
|
cmd := "iptables-save"
|
||||||
|
if fam == IPv6 {
|
||||||
|
cmd = "ip6tables-save"
|
||||||
|
}
|
||||||
|
out, err := runCommand(ctx, cmd, "-c", "-t", "filter")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var live []*Rule
|
||||||
|
for _, line := range out {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
// Rule lines carry a leading [pkts:bytes] token; policy and table
|
||||||
|
// headers do not.
|
||||||
|
if !strings.HasPrefix(line, "[") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, uerr := unmarshalIPTablesRule(line, fam)
|
||||||
|
if uerr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
live = append(live, r)
|
||||||
|
}
|
||||||
|
// Consume each live row at most once so duplicate rules keep distinct counts.
|
||||||
|
used := make([]bool, len(live))
|
||||||
|
for _, r := range rules {
|
||||||
|
if r == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, l := range live {
|
||||||
|
if used[i] || !l.Equal(r, true) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Packets, r.Bytes = l.Packets, l.Bytes
|
||||||
|
used[i] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// applyRuleFiles runs prepare against each family file the rule applies to,
|
// applyRuleFiles runs prepare against each family file the rule applies to,
|
||||||
// staging all temp files first and committing them only once every file has
|
// staging all temp files first and committing them only once every file has
|
||||||
// been prepared successfully. A FamilyAny rule touches both the IPv4 and IPv6
|
// been prepared successfully. A FamilyAny rule touches both the IPv4 and IPv6
|
||||||
|
|
@ -1082,11 +1167,11 @@ func iptChainForDirection(d Direction) string {
|
||||||
|
|
||||||
// logicalStarts maps each entry of rules (the parsed `-A` lines of one chain,
|
// logicalStarts maps each entry of rules (the parsed `-A` lines of one chain,
|
||||||
// in file order) to the 1-based logical-rule position it begins, or 0 when it is
|
// in file order) to the 1-based logical-rule position it begins, or 0 when it is
|
||||||
// not a logical-rule start — an action line coalesced into a preceding LOG line,
|
// not a logical-rule start — an unmodeled (nil) foreign line, an action line
|
||||||
// or a dropped orphan LOG line. It mirrors coalesceLoggedRules exactly so an
|
// coalesced into a preceding LOG line, or a dropped orphan LOG line. It mirrors
|
||||||
// insert/move position aligns with the per-chain numbering GetRules reports: a
|
// GetRules' numbering exactly — Rule.Number promises to mirror the InsertRule/
|
||||||
// LOG line paired with its action is one logical rule beginning at the LOG line,
|
// MoveRule position argument, and GetRules skips lines it cannot parse — so a
|
||||||
// while an orphan LOG line (no matching action after it) begins none. The
|
// caller position never lands relative to a rule GetRules did not report. The
|
||||||
// returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and
|
// returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and
|
||||||
// prepareMoveRuleFile can translate a caller position into a physical line.
|
// prepareMoveRuleFile can translate a caller position into a physical line.
|
||||||
func (f *IPTables) logicalStarts(rules []*Rule) []int {
|
func (f *IPTables) logicalStarts(rules []*Rule) []int {
|
||||||
|
|
@ -1094,7 +1179,12 @@ func (f *IPTables) logicalStarts(rules []*Rule) []int {
|
||||||
pos := 0
|
pos := 0
|
||||||
for i := 0; i < len(rules); i++ {
|
for i := 0; i < len(rules); i++ {
|
||||||
cur := rules[i]
|
cur := rules[i]
|
||||||
if cur != nil && cur.Action == ActionInvalid && cur.Log {
|
// An unmodeled foreign line is invisible to GetRules, so it begins no
|
||||||
|
// logical rule; it stays pinned to the rule that follows it.
|
||||||
|
if cur == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur.Action == ActionInvalid && cur.Log {
|
||||||
var next *Rule
|
var next *Rule
|
||||||
if i+1 < len(rules) {
|
if i+1 < len(rules) {
|
||||||
next = rules[i+1]
|
next = rules[i+1]
|
||||||
|
|
@ -1234,6 +1324,7 @@ func (f *IPTables) stateValue(s ConnState) string {
|
||||||
// marshalMatches builds the iptables-save match tokens for a rule (everything
|
// marshalMatches builds the iptables-save match tokens for a rule (everything
|
||||||
// up to but not including the `-j <target>`), including any rate/connection
|
// up to but not including the `-j <target>`), including any rate/connection
|
||||||
// limit and the identifying comment. MarshalRule and the LOG-line encoder share
|
// limit and the identifying comment. MarshalRule and the LOG-line encoder share
|
||||||
|
// it so a logged rule's two lines carry identical match tokens.
|
||||||
func (f *IPTables) marshalMatches(r *Rule) ([]string, error) {
|
func (f *IPTables) marshalMatches(r *Rule) ([]string, error) {
|
||||||
// A TCPUDP rule has no single-line iptables form; it must be fanned out into a
|
// A TCPUDP rule has no single-line iptables form; it must be fanned out into a
|
||||||
// tcp row and a udp row before reaching this row-level marshaller. Assert the
|
// tcp row and a udp row before reaching this row-level marshaller. Assert the
|
||||||
|
|
@ -1435,11 +1526,12 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fd, err := os.Open(filePath)
|
// Read the file once; the same lines feed both the position precomputation
|
||||||
|
// and the rewrite pass.
|
||||||
|
allLines, err := f.readAllLines(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = fd.Close() }()
|
|
||||||
|
|
||||||
af, err := newAtomicFile(filePath, 0644)
|
af, err := newAtomicFile(filePath, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1457,15 +1549,9 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
|
||||||
// Indexing this as the write pass scans keeps the insert aligned with the
|
// Indexing this as the write pass scans keeps the insert aligned with the
|
||||||
// position GetRules reports and never splits a logged rule's two lines or a
|
// position GetRules reports and never splits a logged rule's two lines or a
|
||||||
// fanned-out tcp/udp pair.
|
// fanned-out tcp/udp pair.
|
||||||
allLines, err := f.readAllLines(filePath)
|
|
||||||
if err != nil {
|
|
||||||
af.Abort()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
chainStarts := f.chainStarts(allLines, expectedChain)
|
chainStarts := f.chainStarts(allLines, expectedChain)
|
||||||
chainIdx := 0
|
chainIdx := 0
|
||||||
|
|
||||||
scanner := bufio.NewScanner(fd)
|
|
||||||
filterFound := false
|
filterFound := false
|
||||||
writtenRule := false
|
writtenRule := false
|
||||||
writeRule := func() {
|
writeRule := func() {
|
||||||
|
|
@ -1475,8 +1561,8 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
|
||||||
writtenRule = true
|
writtenRule = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for scanner.Scan() {
|
for _, raw := range allLines {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line := strings.TrimSpace(raw)
|
||||||
|
|
||||||
if !filterFound {
|
if !filterFound {
|
||||||
if line == "*filter" {
|
if line == "*filter" {
|
||||||
|
|
@ -1504,16 +1590,9 @@ func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int)
|
||||||
_, _ = fmt.Fprintln(af, line)
|
_, _ = fmt.Fprintln(af, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A read error means the staged file is truncated; discard it rather than
|
|
||||||
// installing a partial ruleset.
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
af.Abort()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !writtenRule {
|
if !writtenRule {
|
||||||
af.Abort()
|
af.Abort()
|
||||||
return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file")
|
return nil, fmt.Errorf("failed to write the new rule to the iptables-save file")
|
||||||
}
|
}
|
||||||
|
|
||||||
return af, nil
|
return af, nil
|
||||||
|
|
@ -1706,7 +1785,7 @@ func (f *IPTables) prepareMoveRuleFile(filePath string, r *Rule, position int) (
|
||||||
}
|
}
|
||||||
|
|
||||||
if !inserted {
|
if !inserted {
|
||||||
return nil, fmt.Errorf("we were not able to move the rule in the iptables-save file")
|
return nil, fmt.Errorf("failed to move the rule in the iptables-save file")
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.stageLines(filePath, out)
|
return f.stageLines(filePath, out)
|
||||||
|
|
@ -1748,7 +1827,12 @@ func (f *IPTables) parseNATTarget(tok string) (addr string, port uint16) {
|
||||||
return tok, 0
|
return tok, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING.
|
// UnmarshalNATRule decodes one iptables-save nat line (-A PREROUTING / -A
|
||||||
|
// POSTROUTING ...) into a NATRule. A line the model cannot hold faithfully — a
|
||||||
|
// negated interface/protocol/port match, an unknown protocol, an unsupported
|
||||||
|
// jump, or another chain — is rejected so the raw line stays foreign and is
|
||||||
|
// preserved verbatim; an OUTPUT-chain DNAT, for example, has no distinct model
|
||||||
|
// here and would otherwise be relocated to PREROUTING on Restore.
|
||||||
func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error) {
|
func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error) {
|
||||||
tokens, err := shlex.Split(spec, true)
|
tokens, err := shlex.Split(spec, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1819,18 +1903,37 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error
|
||||||
r.Destination = tokens[i]
|
r.Destination = tokens[i]
|
||||||
}
|
}
|
||||||
case "-i", "--in-interface", "-o", "--out-interface":
|
case "-i", "--in-interface", "-o", "--out-interface":
|
||||||
|
// Interface, protocol and port carry no negated form in the model, so
|
||||||
|
// a `!` here must reject the line — reading `! -o docker0` as a plain
|
||||||
|
// interface match would invert the rule's meaning (Docker's stock
|
||||||
|
// masquerade rule is exactly this shape).
|
||||||
|
if not {
|
||||||
|
return nil, fmt.Errorf("negated interface match is not modeled")
|
||||||
|
}
|
||||||
i++
|
i++
|
||||||
if i >= len(tokens) {
|
if i >= len(tokens) {
|
||||||
return nil, fmt.Errorf("invalid interface parameter")
|
return nil, fmt.Errorf("invalid interface parameter")
|
||||||
}
|
}
|
||||||
r.Interface = tokens[i]
|
r.Interface = tokens[i]
|
||||||
case "-p", "--protocol":
|
case "-p", "--protocol":
|
||||||
|
if not {
|
||||||
|
return nil, fmt.Errorf("negated protocol match is not modeled")
|
||||||
|
}
|
||||||
i++
|
i++
|
||||||
if i >= len(tokens) {
|
if i >= len(tokens) {
|
||||||
return nil, fmt.Errorf("invalid protocol parameter")
|
return nil, fmt.Errorf("invalid protocol parameter")
|
||||||
}
|
}
|
||||||
r.Proto = GetProtocol(tokens[i])
|
proto := GetProtocol(tokens[i])
|
||||||
|
if proto == ProtocolAny {
|
||||||
|
// An unknown protocol token must not silently widen to a
|
||||||
|
// match-every-protocol rule a Restore would re-marshal without -p.
|
||||||
|
return nil, fmt.Errorf("unsupported protocol: %s", tokens[i])
|
||||||
|
}
|
||||||
|
r.Proto = proto
|
||||||
case "--dport", "--destination-port":
|
case "--dport", "--destination-port":
|
||||||
|
if not {
|
||||||
|
return nil, fmt.Errorf("negated port match is not modeled")
|
||||||
|
}
|
||||||
i++
|
i++
|
||||||
if i >= len(tokens) {
|
if i >= len(tokens) {
|
||||||
return nil, fmt.Errorf("invalid dport parameter")
|
return nil, fmt.Errorf("invalid dport parameter")
|
||||||
|
|
@ -1845,6 +1948,9 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error
|
||||||
r.Ports = []PortRange{pr}
|
r.Ports = []PortRange{pr}
|
||||||
}
|
}
|
||||||
case "-m", "--match":
|
case "-m", "--match":
|
||||||
|
if not {
|
||||||
|
return nil, fmt.Errorf("negated match is not modeled")
|
||||||
|
}
|
||||||
i++
|
i++
|
||||||
if i >= len(tokens) {
|
if i >= len(tokens) {
|
||||||
return nil, fmt.Errorf("invalid match parameter")
|
return nil, fmt.Errorf("invalid match parameter")
|
||||||
|
|
@ -1904,8 +2010,11 @@ func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error
|
||||||
return nil, fmt.Errorf("invalid multiport match")
|
return nil, fmt.Errorf("invalid multiport match")
|
||||||
}
|
}
|
||||||
switch tokens[i+1] {
|
switch tokens[i+1] {
|
||||||
case "--dports", "--dport", "--ports", "--port":
|
case "--dports", "--dport":
|
||||||
default:
|
default:
|
||||||
|
// `--ports`/`--port` means source OR destination port; mapping it
|
||||||
|
// onto the destination fields would silently drop the source half
|
||||||
|
// on a re-marshal, so the line stays foreign.
|
||||||
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
|
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
|
||||||
}
|
}
|
||||||
i += 2
|
i += 2
|
||||||
|
|
@ -2184,7 +2293,9 @@ func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) e
|
||||||
return f.writeAllLines(path, updated)
|
return f.writeAllLines(path, updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removal: drop the matching nat rule line.
|
// Removal: drop every matching nat rule line, not just the first, so a chain
|
||||||
|
// holding duplicate equivalent lines comes clean in one call — mirroring the
|
||||||
|
// filter path's removal loop.
|
||||||
if natStart == -1 || commitIdx == -1 {
|
if natStart == -1 || commitIdx == -1 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -2203,7 +2314,7 @@ func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) e
|
||||||
updated = append(updated, raw)
|
updated = append(updated, raw)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if inNat && !removed && !f.IgnoreLine(t) {
|
if inNat && !f.IgnoreLine(t) {
|
||||||
if nr, perr := f.UnmarshalNATRule(t, f.fileFamily(path)); perr == nil && nr.EqualBase(r) {
|
if nr, perr := f.UnmarshalNATRule(t, f.fileFamily(path)); perr == nil && nr.EqualBase(r) {
|
||||||
removed = true
|
removed = true
|
||||||
continue
|
continue
|
||||||
|
|
@ -2537,8 +2648,13 @@ func (f *IPTables) ipsetParseType(fields []string) (Family, SetType) {
|
||||||
func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
||||||
out, err := runCommand(ctx, "ipset", "save")
|
out, err := runCommand(ctx, "ipset", "save")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// ipset not installed, or no sets: nothing to report.
|
// An absent ipset binary means no sets to report; any other failure
|
||||||
return nil, nil
|
// (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{}
|
sets := map[string]*AddressSet{}
|
||||||
var names []string
|
var names []string
|
||||||
|
|
@ -2552,7 +2668,9 @@ func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
||||||
}
|
}
|
||||||
for _, line := range out {
|
for _, line := range out {
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
if len(fields) == 3 && fields[0] == "add" {
|
// An add line may carry entry options (`timeout 600`, `comment "x"`);
|
||||||
|
// the entry itself is still the third field.
|
||||||
|
if len(fields) >= 3 && fields[0] == "add" {
|
||||||
if set, ok := sets[fields[1]]; ok {
|
if set, ok := sets[fields[1]]; ok {
|
||||||
set.Entries = append(set.Entries, fields[2])
|
set.Entries = append(set.Entries, fields[2])
|
||||||
}
|
}
|
||||||
|
|
@ -2841,6 +2959,7 @@ func (f *IPTables) rewriteFilterRules(path string, ruleLines []string) error {
|
||||||
// and writes (PREROUTING/POSTROUTING). rewriteNATRules replaces the rules in these
|
// and writes (PREROUTING/POSTROUTING). rewriteNATRules replaces the rules in these
|
||||||
// chains and preserves every other nat chain verbatim — including OUTPUT, whose
|
// chains and preserves every other nat chain verbatim — including OUTPUT, whose
|
||||||
// locally-generated DNAT the NATRule model cannot represent distinctly (see
|
// locally-generated DNAT the NATRule model cannot represent distinctly (see
|
||||||
|
// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING.
|
||||||
func (f *IPTables) managedNATChain(chain string) bool {
|
func (f *IPTables) managedNATChain(chain string) bool {
|
||||||
switch chain {
|
switch chain {
|
||||||
case "PREROUTING", "POSTROUTING":
|
case "PREROUTING", "POSTROUTING":
|
||||||
|
|
@ -2849,19 +2968,51 @@ func (f *IPTables) managedNATChain(chain string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preservedNATLines returns the indices of managed-chain (-A PREROUTING /
|
||||||
|
// POSTROUTING) lines a rewrite must keep verbatim because UnmarshalNATRule
|
||||||
|
// cannot model them: an unsupported jump (-j DOCKER, -j RETURN), an unmodeled
|
||||||
|
// or negated match, a source-port match. Such a line is invisible to
|
||||||
|
// GetNATRules, so it never appears in the desired set and would otherwise be
|
||||||
|
// deleted — severing, for example, Docker's port publishing on a Backup/Restore
|
||||||
|
// round trip. It is the nat counterpart of preservedFilterLines.
|
||||||
|
func (f *IPTables) preservedNATLines(lines []string) map[int]bool {
|
||||||
|
preserved := map[int]bool{}
|
||||||
|
inNat := false
|
||||||
|
for i, line := range lines {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(t, "*") {
|
||||||
|
inNat = t == "*nat"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := f.ruleLineBody(t)
|
||||||
|
if !inNat || !strings.HasPrefix(body, "-A ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !f.managedNATChain(f.chainOf(body)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := f.UnmarshalNATRule(t, FamilyAny); err != nil {
|
||||||
|
preserved[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return preserved
|
||||||
|
}
|
||||||
|
|
||||||
// rewriteNATRules atomically rewrites path so that the *nat table's rule lines in
|
// rewriteNATRules atomically rewrites path so that the *nat table's rule lines in
|
||||||
// the managed chains are exactly natLines, leaving the chain-policy lines, any
|
// the managed chains are exactly natLines, leaving the chain-policy lines, any
|
||||||
// user-defined nat chain, the *filter table and all other content untouched. A
|
// user-defined nat chain, unmodeled managed-chain lines, the *filter table and
|
||||||
// file with no *nat table gains one. It is the nat counterpart of
|
// all other content untouched. A file with no *nat table gains one. It is the
|
||||||
|
// nat counterpart of rewriteFilterRules.
|
||||||
func (f *IPTables) rewriteNATRules(path string, natLines []string) error {
|
func (f *IPTables) rewriteNATRules(path string, natLines []string) error {
|
||||||
lines, err := f.readAllLines(path)
|
lines, err := f.readAllLines(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
preserved := f.preservedNATLines(lines)
|
||||||
out := make([]string, 0, len(lines)+len(natLines))
|
out := make([]string, 0, len(lines)+len(natLines))
|
||||||
inNat := false
|
inNat := false
|
||||||
inserted := false
|
inserted := false
|
||||||
for _, line := range lines {
|
for idx, line := range lines {
|
||||||
t := strings.TrimSpace(line)
|
t := strings.TrimSpace(line)
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(t, "*"):
|
case strings.HasPrefix(t, "*"):
|
||||||
|
|
@ -2876,9 +3027,10 @@ func (f *IPTables) rewriteNATRules(path string, natLines []string) error {
|
||||||
inNat = false
|
inNat = false
|
||||||
case inNat && strings.HasPrefix(f.ruleLineBody(t), "-A "):
|
case inNat && strings.HasPrefix(f.ruleLineBody(t), "-A "):
|
||||||
// Drop an existing rule in a managed chain — the desired set replaces it —
|
// Drop an existing rule in a managed chain — the desired set replaces it —
|
||||||
// but preserve a rule in a user-defined nat chain verbatim, mirroring how
|
// but preserve a rule in a user-defined nat chain, and an unmodeled
|
||||||
// rewriteFilterRules preserves FORWARD/custom-chain rules.
|
// managed-chain line, verbatim, mirroring how rewriteFilterRules
|
||||||
if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) {
|
// preserves FORWARD/custom-chain rules and unmodeled filter lines.
|
||||||
|
if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) || preserved[idx] {
|
||||||
out = append(out, line)
|
out = append(out, line)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
@ -3154,7 +3306,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan each line to find where we should insert our rule.
|
// Scan each line to find the insertion point.
|
||||||
scanner := bufio.NewScanner(fd)
|
scanner := bufio.NewScanner(fd)
|
||||||
writtenRule := false
|
writtenRule := false
|
||||||
filterFound := false
|
filterFound := false
|
||||||
|
|
@ -3165,7 +3317,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
|
||||||
writtenRule = true
|
writtenRule = true
|
||||||
}
|
}
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
// Trim line and check if we skip the line.
|
// Trim the line and check whether it is skipped.
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
// Look for the filter, and skip if not reached.
|
// Look for the filter, and skip if not reached.
|
||||||
|
|
@ -3202,7 +3354,7 @@ func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, er
|
||||||
// A rule that was never written means the filter table was malformed.
|
// A rule that was never written means the filter table was malformed.
|
||||||
if !writtenRule {
|
if !writtenRule {
|
||||||
af.Abort()
|
af.Abort()
|
||||||
return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file")
|
return nil, fmt.Errorf("failed to write the new rule to the iptables-save file")
|
||||||
}
|
}
|
||||||
|
|
||||||
return af, nil
|
return af, nil
|
||||||
|
|
|
||||||
|
|
@ -818,7 +818,9 @@ func TestIPTablesMoveForeignChainPosition(t *testing.T) {
|
||||||
// TestIPTablesLogicalStarts checks that logicalStarts numbers physical `-A`
|
// TestIPTablesLogicalStarts checks that logicalStarts numbers physical `-A`
|
||||||
// lines the same way GetRules (coalesceLoggedRules) does: a LOG line paired with
|
// lines the same way GetRules (coalesceLoggedRules) does: a LOG line paired with
|
||||||
// its action is one logical rule beginning at the LOG line, an orphan LOG line
|
// its action is one logical rule beginning at the LOG line, an orphan LOG line
|
||||||
// begins none, and a foreign line the parser rejects (nil) is an ordinary rule.
|
// begins none, and a foreign line the parser rejects (nil) begins none either:
|
||||||
|
// GetRules never reports it, and Rule.Number promises to mirror the position
|
||||||
|
// argument of InsertRule/MoveRule, so the two numberings must not diverge.
|
||||||
func TestIPTablesLogicalStarts(t *testing.T) {
|
func TestIPTablesLogicalStarts(t *testing.T) {
|
||||||
f := new(IPTables)
|
f := new(IPTables)
|
||||||
logRule := func(proto Protocol, port uint16) *Rule {
|
logRule := func(proto Protocol, port uint16) *Rule {
|
||||||
|
|
@ -837,7 +839,9 @@ func TestIPTablesLogicalStarts(t *testing.T) {
|
||||||
{"single orphan log dropped", []*Rule{act(TCP, 22), logRule(UDP, 53), act(TCP, 80)}, []int{1, 0, 2}},
|
{"single orphan log dropped", []*Rule{act(TCP, 22), logRule(UDP, 53), act(TCP, 80)}, []int{1, 0, 2}},
|
||||||
{"consecutive orphan logs dropped", []*Rule{logRule(UDP, 53), logRule(UDP, 123), act(TCP, 22), act(TCP, 80)}, []int{0, 0, 1, 2}},
|
{"consecutive orphan logs dropped", []*Rule{logRule(UDP, 53), logRule(UDP, 123), act(TCP, 22), act(TCP, 80)}, []int{0, 0, 1, 2}},
|
||||||
{"trailing orphan log", []*Rule{act(TCP, 22), logRule(UDP, 53)}, []int{1, 0}},
|
{"trailing orphan log", []*Rule{act(TCP, 22), logRule(UDP, 53)}, []int{1, 0}},
|
||||||
{"foreign nil line counts as a rule", []*Rule{nil, act(TCP, 22)}, []int{1, 2}},
|
{"foreign nil line begins no logical rule", []*Rule{nil, act(TCP, 22)}, []int{0, 1}},
|
||||||
|
{"foreign nil line between rules", []*Rule{act(TCP, 22), nil, act(TCP, 80)}, []int{1, 0, 2}},
|
||||||
|
{"foreign nil line between log and action breaks the pair", []*Rule{logRule(TCP, 22), nil, act(TCP, 22)}, []int{0, 0, 1}},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|
@ -1916,3 +1920,122 @@ func TestIPTablesMarshalTCPUDPErrors(t *testing.T) {
|
||||||
_, err := fw.MarshalRule(&Rule{Proto: TCPUDP, Port: 80, Action: Accept})
|
_, err := fw.MarshalRule(&Rule{Proto: TCPUDP, Port: 80, Action: Accept})
|
||||||
require.Error(t, err, "MarshalRule must reject an unexpanded TCPUDP rule")
|
require.Error(t, err, "MarshalRule must reject an unexpanded TCPUDP rule")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIPTablesNATParserRejectsUnmodeledMatches pins the shapes UnmarshalNATRule
|
||||||
|
// must leave foreign: a negated interface/protocol/port match (reading
|
||||||
|
// `! -o docker0` as a plain match would invert Docker's stock masquerade rule),
|
||||||
|
// an unknown protocol token, and a source-or-destination multiport option.
|
||||||
|
func TestIPTablesNATParserRejectsUnmodeledMatches(t *testing.T) {
|
||||||
|
fw := new(IPTables)
|
||||||
|
foreign := []string{
|
||||||
|
"-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE",
|
||||||
|
"-A PREROUTING ! -i eth0 -p tcp -m tcp --dport 80 -j REDIRECT --to-ports 8080",
|
||||||
|
"-A PREROUTING ! -p tcp -j REDIRECT --to-ports 8080",
|
||||||
|
"-A PREROUTING -p tcp -m tcp ! --dport 80 -j REDIRECT --to-ports 8080",
|
||||||
|
"-A PREROUTING -p udplite -j REDIRECT --to-ports 8080",
|
||||||
|
"-A PREROUTING -p 47 -j REDIRECT --to-ports 8080",
|
||||||
|
"-A PREROUTING -p udp -m multiport --ports 5060 -j REDIRECT --to-ports 5061",
|
||||||
|
}
|
||||||
|
for _, spec := range foreign {
|
||||||
|
_, err := fw.UnmarshalNATRule(spec, IPv4)
|
||||||
|
require.Error(t, err, "line must stay foreign: %s", spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A negated address still models, and the plain forms still parse.
|
||||||
|
r, err := fw.UnmarshalNATRule("-A POSTROUTING ! -s 172.17.0.0/16 -o eth0 -j MASQUERADE", IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "!172.17.0.0/16", r.Source)
|
||||||
|
require.Equal(t, "eth0", r.Interface)
|
||||||
|
r, err = fw.UnmarshalNATRule("-A PREROUTING -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.0.0.5:80", IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, DNAT, r.Kind)
|
||||||
|
require.EqualValues(t, 8080, r.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIPTablesRewriteNATPreservesUnmodeled pins that a Restore-style nat rewrite
|
||||||
|
// keeps managed-chain lines the model cannot hold — Docker's addrtype jump — while
|
||||||
|
// still replacing the modeled rules.
|
||||||
|
func TestIPTablesRewriteNATPreservesUnmodeled(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
save := "*nat\n:PREROUTING ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n" +
|
||||||
|
"-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER\n" +
|
||||||
|
"-A PREROUTING -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.0.0.5:80\n" +
|
||||||
|
"-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE\n" +
|
||||||
|
"COMMIT\n*filter\n:INPUT ACCEPT [0:0]\nCOMMIT\n"
|
||||||
|
p := filepath.Join(dir, "iptables")
|
||||||
|
require.NoError(t, os.WriteFile(p, []byte(save), 0644))
|
||||||
|
fw := &IPTables{IP4Path: p, IP6Path: filepath.Join(dir, "ip6tables")}
|
||||||
|
|
||||||
|
require.NoError(t, fw.rewriteNATRules(p, []string{"-A PREROUTING -p tcp -m tcp --dport 9090 -j DNAT --to-destination 10.0.0.6:90"}))
|
||||||
|
got, err := os.ReadFile(p)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := string(got)
|
||||||
|
require.Contains(t, body, "-j DOCKER", "Docker's unmodeled prerouting jump must survive the rewrite")
|
||||||
|
require.Contains(t, body, "! -o docker0", "Docker's negated masquerade must survive the rewrite")
|
||||||
|
require.Contains(t, body, "--dport 9090", "the desired rule must be written")
|
||||||
|
require.NotContains(t, body, "--dport 8080", "the replaced modeled rule must be gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIPTablesLogPairSplitByForeignLine pins that a LOG line and an action line
|
||||||
|
// separated by an unmodeled foreign line are not coalesced into one logged rule:
|
||||||
|
// the pair reads apart everywhere (GetRules, positions, removal), so reporting a
|
||||||
|
// merged rule would surface one no removal could ever find.
|
||||||
|
func TestIPTablesLogPairSplitByForeignLine(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
save := "*filter\n:INPUT ACCEPT [0:0]\n" +
|
||||||
|
"-A INPUT -p tcp -m tcp --dport 80 -j LOG --log-prefix \"web: \"\n" +
|
||||||
|
"-A INPUT -m recent --name probe --set -j DROP\n" +
|
||||||
|
"-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" +
|
||||||
|
"COMMIT\n"
|
||||||
|
p := filepath.Join(dir, "iptables")
|
||||||
|
require.NoError(t, os.WriteFile(p, []byte(save), 0644))
|
||||||
|
fw := &IPTables{IP4Path: p, IP6Path: filepath.Join(dir, "ip6tables")}
|
||||||
|
|
||||||
|
rules, err := fw.parseFilterFile(p, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rules, 1, "only the plain accept is a modeled rule")
|
||||||
|
require.False(t, rules[0].Log, "the accept must not inherit the separated LOG line")
|
||||||
|
|
||||||
|
// Removing the reported rule must find its line even with the split pair.
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "ip6tables"), []byte("*filter\nCOMMIT\n"), 0644))
|
||||||
|
require.NoError(t, fw.RemoveRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept}))
|
||||||
|
got, err := os.ReadFile(p)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(got), "-j ACCEPT", "the reported rule must be removable")
|
||||||
|
require.Contains(t, string(got), "-j LOG", "the orphaned LOG line is unmodeled and preserved")
|
||||||
|
require.Contains(t, string(got), "-m recent", "the foreign line is preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIPTablesInsertPositionSkipsForeignLine pins that Insert positions count
|
||||||
|
// only the rules GetRules reports: an unmodeled foreign line holds no position,
|
||||||
|
// so Rule.Number and the InsertRule position argument cannot diverge.
|
||||||
|
func TestIPTablesInsertPositionSkipsForeignLine(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
save := "*filter\n:INPUT ACCEPT [0:0]\n" +
|
||||||
|
"-A INPUT -m recent --name probe --set -j DROP\n" +
|
||||||
|
"-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" +
|
||||||
|
"-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" +
|
||||||
|
"COMMIT\n"
|
||||||
|
p4 := filepath.Join(dir, "iptables")
|
||||||
|
p6 := filepath.Join(dir, "ip6tables")
|
||||||
|
require.NoError(t, os.WriteFile(p4, []byte(save), 0644))
|
||||||
|
require.NoError(t, os.WriteFile(p6, []byte("*filter\nCOMMIT\n"), 0644))
|
||||||
|
fw := &IPTables{IP4Path: p4, IP6Path: p6}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// GetRules reports #1 dport 22, #2 dport 80. Insert at 2 = between them.
|
||||||
|
require.NoError(t, fw.InsertRule(ctx, "", 2, &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept}))
|
||||||
|
rules, err := fw.GetRules(ctx, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
byPort := map[uint16]int{}
|
||||||
|
for _, r := range rules {
|
||||||
|
byPort[r.Port] = r.Number
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, byPort[22])
|
||||||
|
require.Equal(t, 2, byPort[443], "position 2 must land between the two modeled rules, not before dport 22")
|
||||||
|
require.Equal(t, 3, byPort[80])
|
||||||
|
got, err := os.ReadFile(p4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(got), "-m recent", "the foreign line survives the insert")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,50 +2,38 @@ package firewall
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewManager gets a firewall manager for this server. The context bounds the
|
// NewManager gets a firewall manager for this server, probing the higher-level
|
||||||
|
// managers first: firewalld, then ufw/csf/apf, then plain iptables, and
|
||||||
|
// nftables last, so a manager that is itself backed by iptables or nftables is
|
||||||
|
// preferred over managing its tables behind its back. The context bounds the
|
||||||
// detection probes (each shells out or opens a D-Bus/systemd connection).
|
// detection probes (each shells out or opens a D-Bus/systemd connection).
|
||||||
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
|
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
|
||||||
// First check for firewalld as there are no firewall managers that use it.
|
// A probe error usually means "not installed", but it can also be a
|
||||||
firewalld, err := NewFirewallD(ctx, rulePrefix)
|
// transient failure on a host whose firewall IS that manager (a D-Bus
|
||||||
if err == nil {
|
// hiccup with firewalld running); every probe's reason is carried in the
|
||||||
return firewalld, nil
|
// final error so a mis-detection is diagnosable.
|
||||||
|
var errs []error
|
||||||
|
probes := []struct {
|
||||||
|
name string
|
||||||
|
try func() (Manager, error)
|
||||||
|
}{
|
||||||
|
{"firewalld", func() (Manager, error) { return NewFirewallD(ctx, rulePrefix) }},
|
||||||
|
{"ufw", func() (Manager, error) { return NewUFW(ctx, rulePrefix) }},
|
||||||
|
{"csf", func() (Manager, error) { return NewCSF(ctx, rulePrefix) }},
|
||||||
|
{"apf", func() (Manager, error) { return NewAPF(ctx, rulePrefix) }},
|
||||||
|
{"iptables", func() (Manager, error) { return NewIPTables(ctx, rulePrefix) }},
|
||||||
|
{"nftables", func() (Manager, error) { return NewNFT(ctx, rulePrefix) }},
|
||||||
}
|
}
|
||||||
|
for _, p := range probes {
|
||||||
// Check if Ubuntu's Uncomplicated Firewall (UFW) is installed.
|
mgr, err := p.try()
|
||||||
ufw, err := NewUFW(ctx, rulePrefix)
|
if err == nil {
|
||||||
if err == nil {
|
return mgr, nil
|
||||||
return ufw, nil
|
}
|
||||||
|
errs = append(errs, fmt.Errorf("%s: %w", p.name, err))
|
||||||
}
|
}
|
||||||
|
return nil, fmt.Errorf("no firewall manager found: %w", errors.Join(errs...))
|
||||||
// Check if ConfigServer Security & Firewall is installed.
|
|
||||||
csf, err := NewCSF(ctx, rulePrefix)
|
|
||||||
if err == nil {
|
|
||||||
return csf, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Advanced Policy Firewall is installed.
|
|
||||||
apf, err := NewAPF(ctx, rulePrefix)
|
|
||||||
if err == nil {
|
|
||||||
return apf, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// After checking common firewall managers that use iptables, try iptables.
|
|
||||||
iptables, err := NewIPTables(ctx, rulePrefix)
|
|
||||||
if err == nil {
|
|
||||||
return iptables, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// As a last resort, manage nftables directly. This is tried last so that a
|
|
||||||
// higher-level manager (which may itself be backed by nftables) is preferred
|
|
||||||
// when one is present.
|
|
||||||
nft, err := NewNFT(ctx, rulePrefix)
|
|
||||||
if err == nil {
|
|
||||||
return nft, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no manager found via the known managers, fail.
|
|
||||||
return nil, fmt.Errorf("no firewall manager found")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewManager gets a firewall manager for this server; Windows has a single
|
||||||
|
// backend, the Windows Filtering Platform. The context bounds the probe.
|
||||||
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
|
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
|
||||||
// Attempt to connect to WFP.
|
|
||||||
wf, err := NewWF(ctx, rulePrefix)
|
wf, err := NewWF(ctx, rulePrefix)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return wf, nil
|
return wf, nil
|
||||||
}
|
}
|
||||||
|
return nil, fmt.Errorf("no firewall manager found: %w", err)
|
||||||
return nil, fmt.Errorf("no firewall manager found")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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).
|
// A non-default burst (nft's default is 5, which normalizes to the unset 0).
|
||||||
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 10}},
|
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 10}},
|
||||||
{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}},
|
{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}},
|
||||||
|
// Per-source counting keys on the source address in a named meter set
|
||||||
|
// (TestNFTPerSourceConnLimit covers the listed-form parses).
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}},
|
||||||
{Family: IPv4, Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept},
|
{Family: IPv4, Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept},
|
||||||
{Family: IPv4, Proto: TCP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
|
{Family: IPv4, Proto: TCP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
|
||||||
}
|
}
|
||||||
|
|
@ -559,10 +562,6 @@ func TestNFTLogLimitRoundTrip(t *testing.T) {
|
||||||
require.NoError(t, err, "expr %q", expr)
|
require.NoError(t, err, "expr %q", expr)
|
||||||
require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got)
|
require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-source connection limiting is not expressible in this model.
|
|
||||||
_, _, err := f.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}})
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNFTNATRoundTrip(t *testing.T) {
|
func TestNFTNATRoundTrip(t *testing.T) {
|
||||||
|
|
@ -740,3 +739,155 @@ func TestNFTNATRejectsMergedProtocol(t *testing.T) {
|
||||||
_, _, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCPUDP, Port: 80, ToAddress: "192.0.2.1"})
|
_, _, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCPUDP, Port: 80, ToAddress: "192.0.2.1"})
|
||||||
require.ErrorIs(t, err, ErrUnsupportedNAT)
|
require.ErrorIs(t, err, ErrUnsupportedNAT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A LogPrefix or comment with consecutive interior spaces must round-trip:
|
||||||
|
// LogPrefix is part of rule identity, so a collapsed read-back would never match
|
||||||
|
// its own rule and Sync would add a duplicate row each pass.
|
||||||
|
func TestNFTQuotedSpacingRoundTrip(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
orig := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop,
|
||||||
|
Log: true, LogPrefix: "FW DROP: ", Comment: "web block # one"}
|
||||||
|
chain, expr, err := fw.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
parsed, handle, err := fw.UnmarshalRule(expr+" # handle 9", chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "9", handle)
|
||||||
|
require.Equal(t, "FW DROP: ", parsed.LogPrefix, "interior spacing must survive the round trip")
|
||||||
|
require.Equal(t, "web block # one", parsed.Comment)
|
||||||
|
require.True(t, parsed.Equal(orig, true), "want %+v got %+v", orig, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unrecognized single l4proto token is a foreign rule's protocol the model
|
||||||
|
// cannot hold; it must fail the parse (leaving the row opaque) rather than read
|
||||||
|
// back as a match-every-protocol rule.
|
||||||
|
func TestNFTUnknownL4ProtoRejected(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
_, _, err := fw.UnmarshalRule("meta l4proto igmp accept # handle 3", "input")
|
||||||
|
require.Error(t, err)
|
||||||
|
_, _, err = fw.UnmarshalRule("meta l4proto 253 accept # handle 4", "input")
|
||||||
|
require.Error(t, err)
|
||||||
|
// The one modeled set and the plain transports still parse.
|
||||||
|
r, _, err := fw.UnmarshalRule("meta l4proto { tcp, udp } th dport 53 accept # handle 5", "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, TCPUDP, r.Proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeNFTName must never yield an identifier nftables rejects: identifiers
|
||||||
|
// begin with a letter.
|
||||||
|
func TestNFTSanitizeNameLeadingDigit(t *testing.T) {
|
||||||
|
require.Equal(t, "fw_9lives", sanitizeNFTName("9lives"))
|
||||||
|
require.Equal(t, "myapp", sanitizeNFTName("myapp"))
|
||||||
|
require.Equal(t, NFTDefaultTable, sanitizeNFTName("!!!"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// lineHandle pulls the trailing handle marker off a rule line the model cannot
|
||||||
|
// parse, so the row still gets an opaque slot; a quoted comment containing the
|
||||||
|
// word "handle" cannot fool the end-anchored scan.
|
||||||
|
func TestNFTLineHandle(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
require.Equal(t, "12", fw.lineHandle(`meta mark 0x1 accept # handle 12`))
|
||||||
|
require.Equal(t, "7", fw.lineHandle(`tcp dport 22 accept comment "keep handle steady" # handle 7`))
|
||||||
|
require.Equal(t, "", fw.lineHandle("type filter hook input priority filter; policy accept;"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// physicalIndex maps a 1-based modeled-row position to the physical index,
|
||||||
|
// keeping opaque rows pinned before the slot.
|
||||||
|
func TestNFTPhysicalIndex(t *testing.T) {
|
||||||
|
a, b := &Rule{Port: 1}, &Rule{Port: 2}
|
||||||
|
rows := []*Rule{nil, a, nil, b}
|
||||||
|
require.Equal(t, 1, physicalIndex(rows, 1))
|
||||||
|
require.Equal(t, 3, physicalIndex(rows, 2))
|
||||||
|
require.Equal(t, 4, physicalIndex(rows, 3), "past the last modeled row appends")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A family-agnostic set-referencing rule cannot be rendered faithfully — a named
|
||||||
|
// set is family-typed — so the entry points pin it to the referenced set's own
|
||||||
|
// family (resolveSetRefFamily) before marshalling, and the marshaller guards
|
||||||
|
// against an unresolved one reaching it rather than silently pinning to IPv4.
|
||||||
|
func TestNFTSetRefRequiresConcreteFamily(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
_, _, err := fw.MarshalRule(&Rule{Source: "blocklist", Action: Drop})
|
||||||
|
require.Error(t, err, "an unresolved family-agnostic set reference must not marshal")
|
||||||
|
_, _, err = fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Action: Drop})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// A NAT rule whose only family signal is the set reference is equally
|
||||||
|
// unrenderable; one with a concrete address is implied by it and passes.
|
||||||
|
_, _, err = fw.MarshalNATRule(&NATRule{Kind: Masquerade, Source: "natpool"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, _, err = fw.MarshalNATRule(&NATRule{Kind: SNAT, Source: "natpool", ToAddress: "192.0.2.1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A per-source connection limit marshals as a named meter keyed on the source
|
||||||
|
// address and reads back from nft's normalized `add @set { ... }` listing (and
|
||||||
|
// the written `meter`/`update` spellings). The meter key pins the family, so no
|
||||||
|
// nfproto match is emitted — nft would fold it away and the text would never
|
||||||
|
// round-trip.
|
||||||
|
func TestNFTPerSourceConnLimit(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
|
||||||
|
r := &Rule{Family: IPv4, Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}
|
||||||
|
chain, expr, err := fw.MarshalRule(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "input", chain)
|
||||||
|
require.Contains(t, expr, "meter cl")
|
||||||
|
require.Contains(t, expr, "{ ip saddr ct count over 15 }")
|
||||||
|
require.NotContains(t, expr, "nfproto", "the meter key already pins the family; an nfproto match would not round-trip")
|
||||||
|
|
||||||
|
// The written form parses back to the same rule.
|
||||||
|
parsed, _, err := fw.UnmarshalRule(expr, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, parsed.Equal(r, true), "written form mismatch: %+v vs %+v", parsed, r)
|
||||||
|
|
||||||
|
// nft lists the meter as an `add @set` statement; the parse captures the
|
||||||
|
// count, the per-source flag, the family from the key, and the set name for
|
||||||
|
// removal-time cleanup.
|
||||||
|
listed := "tcp dport 8081 add @cl4a5b { ip saddr ct count over 15 } counter packets 0 bytes 0 reject with icmp port-unreachable # handle 7"
|
||||||
|
parsed, handle, err := fw.UnmarshalRule(listed, "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "7", handle)
|
||||||
|
require.True(t, parsed.Equal(r, true), "listed form mismatch: %+v vs %+v", parsed, r)
|
||||||
|
require.Equal(t, "cl4a5b", parsed.meterSet, "the set name must be captured for removal-time cleanup")
|
||||||
|
|
||||||
|
// The IPv6 key reads back as an IPv6 rule.
|
||||||
|
listed6 := "tcp dport 8081 add @cl6f { ip6 saddr ct count over 15 } counter packets 0 bytes 0 reject # handle 9"
|
||||||
|
parsed6, _, err := fw.UnmarshalRule(listed6, "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, IPv6, parsed6.Family, "the meter key must pin the family")
|
||||||
|
require.True(t, parsed6.ConnLimit != nil && parsed6.ConnLimit.PerSource && parsed6.ConnLimit.Count == 15)
|
||||||
|
|
||||||
|
// The `update` spelling (a timeout-refreshing meter) parses the same way.
|
||||||
|
updated := "tcp dport 8081 update @cl4a5b { ip saddr ct count over 15 } counter packets 0 bytes 0 drop # handle 11"
|
||||||
|
parsedU, _, err := fw.UnmarshalRule(updated, "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, parsedU.ConnLimit != nil && parsedU.ConnLimit.PerSource)
|
||||||
|
|
||||||
|
// nft 1.0.x echoes the written meter spelling back, with a size qualifier.
|
||||||
|
legacy := "tcp dport 8081 meter cl4a5b size 65535 { ip saddr ct count over 15 } counter packets 0 bytes 0 reject with icmp port-unreachable # handle 13"
|
||||||
|
parsedL, _, err := fw.UnmarshalRule(legacy, "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, parsedL.Equal(r, true), "nft 1.0.x meter listing mismatch: %+v vs %+v", parsedL, r)
|
||||||
|
require.Equal(t, "cl4a5b", parsedL.meterSet)
|
||||||
|
|
||||||
|
// An unmodeled dynamic-set body fails the parse (the row stays opaque)
|
||||||
|
// rather than reading back as a wrong rule.
|
||||||
|
_, _, err = fw.UnmarshalRule("tcp dport 80 add @tracker { ip saddr limit rate 10/second } counter drop # handle 12", "input")
|
||||||
|
require.Error(t, err, "a non-connlimit dynamic-set body is not modeled")
|
||||||
|
|
||||||
|
// A FamilyAny per-source rule has no single row; the entry points fan it out
|
||||||
|
// before marshalling, so the marshaller itself refuses it.
|
||||||
|
_, _, err = fw.MarshalRule(&Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// The meter name is deterministic for one rule and distinct across rules.
|
||||||
|
r2 := &Rule{Family: IPv4, Proto: TCP, Port: 8082, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}
|
||||||
|
require.Equal(t, fw.meterName("input", r), fw.meterName("input", r), "same rule, same set")
|
||||||
|
require.NotEqual(t, fw.meterName("input", r), fw.meterName("input", r2), "distinct rules must not share counting state")
|
||||||
|
|
||||||
|
// A global (non-per-source) limit keeps the plain ct count form.
|
||||||
|
_, exprGlobal, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, exprGlobal, "ct count over 20")
|
||||||
|
require.NotContains(t, exprGlobal, "meter")
|
||||||
|
}
|
||||||
|
|
|
||||||
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
|
// 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
|
// a single rule (a range, kept as one token, does). Report PortList as
|
||||||
// unsupported to reflect that; callers open several ports with a rule each.
|
// unsupported to reflect that; callers open several ports with a rule each.
|
||||||
PortList: false,
|
PortList: false,
|
||||||
ConnState: false,
|
PortPair: true,
|
||||||
InterfaceMatch: true,
|
ConnState: false,
|
||||||
Logging: true,
|
InterfaceMatch: true,
|
||||||
RateLimit: true,
|
Logging: true,
|
||||||
ConnLimit: true,
|
RateLimit: true,
|
||||||
NAT: true,
|
ConnLimit: true,
|
||||||
RuleOrdering: true,
|
NAT: true,
|
||||||
DefaultPolicy: false,
|
RuleOrdering: true,
|
||||||
RuleCounters: true,
|
DefaultPolicy: false,
|
||||||
AddressSets: true,
|
RuleCounters: true,
|
||||||
Comments: 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
|
// A continuation line annotates the rule just parsed rather than starting a
|
||||||
// new one; pull its Packets/Bytes into that rule when present.
|
// new one; pull its Packets/Bytes into that rule when present. The verbose
|
||||||
|
// listing prints counters under every rule, including an unmodeled one held
|
||||||
|
// as an opaque nil row, whose counters have nowhere to go.
|
||||||
if strings.HasPrefix(line, "[") {
|
if strings.HasPrefix(line, "[") {
|
||||||
if len(rules) > 0 {
|
if len(rules) > 0 && rules[len(rules)-1] != nil {
|
||||||
if p, b, ok := f.parseRuleCounters(line); ok {
|
if p, b, ok := f.parseRuleCounters(line); ok {
|
||||||
rules[len(rules)-1].Packets = p
|
rules[len(rules)-1].Packets = p
|
||||||
rules[len(rules)-1].Bytes = b
|
rules[len(rules)-1].Bytes = b
|
||||||
|
|
@ -977,6 +983,10 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
|
|
||||||
|
// srcScope is true between `from` and `to`, where a `port` token is a
|
||||||
|
// source-port match this model cannot hold; erroring keeps the line opaque
|
||||||
|
// rather than mis-reading it as a destination-port rule.
|
||||||
|
srcScope := false
|
||||||
for ; i < len(tokens); i++ {
|
for ; i < len(tokens); i++ {
|
||||||
switch tokens[i] {
|
switch tokens[i] {
|
||||||
case "pass", "quick", "log":
|
case "pass", "quick", "log":
|
||||||
|
|
@ -1005,6 +1015,7 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
|
||||||
return nil, fmt.Errorf("unsupported protocol: %s", tokens[i])
|
return nil, fmt.Errorf("unsupported protocol: %s", tokens[i])
|
||||||
}
|
}
|
||||||
case "from":
|
case "from":
|
||||||
|
srcScope = true
|
||||||
val, neg, next, err := f.parseAddr(tokens, i+1)
|
val, neg, next, err := f.parseAddr(tokens, i+1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -1014,6 +1025,7 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
|
||||||
r.Source = neg + f.stripTable(val)
|
r.Source = neg + f.stripTable(val)
|
||||||
}
|
}
|
||||||
case "to":
|
case "to":
|
||||||
|
srcScope = false
|
||||||
val, neg, next, err := f.parseAddr(tokens, i+1)
|
val, neg, next, err := f.parseAddr(tokens, i+1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -1023,6 +1035,9 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
|
||||||
r.Destination = neg + f.stripTable(val)
|
r.Destination = neg + f.stripTable(val)
|
||||||
}
|
}
|
||||||
case "port":
|
case "port":
|
||||||
|
if srcScope {
|
||||||
|
return nil, fmt.Errorf("source port match is not modeled")
|
||||||
|
}
|
||||||
i++
|
i++
|
||||||
if i >= len(tokens) {
|
if i >= len(tokens) {
|
||||||
return nil, fmt.Errorf("missing port value")
|
return nil, fmt.Errorf("missing port value")
|
||||||
|
|
@ -1050,8 +1065,13 @@ func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
|
||||||
}
|
}
|
||||||
target := tokens[i]
|
target := tokens[i]
|
||||||
if strings.HasPrefix(target, "(") {
|
if strings.HasPrefix(target, "(") {
|
||||||
// A dynamic interface address is masquerade.
|
// A dynamic interface address is masquerade. A port pool after it
|
||||||
|
// (`port 1024:65535`) has no single ToPort to map onto, so the line
|
||||||
|
// stays opaque.
|
||||||
r.Kind = Masquerade
|
r.Kind = Masquerade
|
||||||
|
if i+1 < len(tokens) && tokens[i+1] == "port" {
|
||||||
|
return nil, fmt.Errorf("nat target port pool is not modeled")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
r.ToAddress = target
|
r.ToAddress = target
|
||||||
// An optional `port N` gives the translation port. pfctl prints a
|
// An optional `port N` gives the translation port. pfctl prints a
|
||||||
|
|
@ -1223,6 +1243,27 @@ var pfFilterKeywords = map[string]bool{
|
||||||
"antispoof": true,
|
"antispoof": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// marshalExpanded renders rules as anchor filter lines, fanning a DirAny rule
|
||||||
|
// into an inbound rule plus its swapped outbound rule and a TCPUDP rule into a
|
||||||
|
// tcp rule and a udp rule; pf stores each direction/transport as its own row.
|
||||||
|
// Restore and ReplaceRulesBatch share it so a merged rule from a portable
|
||||||
|
// backup expands exactly as the add paths expand it.
|
||||||
|
func (f *PF) marshalExpanded(rules []*Rule) ([]string, error) {
|
||||||
|
var lines []string
|
||||||
|
for _, top := range rules {
|
||||||
|
for _, dir := range expandDirections(top) {
|
||||||
|
for _, r := range expandProtocols(dir) {
|
||||||
|
line, err := f.MarshalRule(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
// loadAnchor replaces the anchor's ruleset with the provided rules. pf requires
|
// loadAnchor replaces the anchor's ruleset with the provided rules. pf requires
|
||||||
// nat/rdr (translation) rules to precede filter rules in the ruleset, so they
|
// nat/rdr (translation) rules to precede filter rules in the ruleset, so they
|
||||||
// are written first. An empty combined set flushes the anchor.
|
// are written first. An empty combined set flushes the anchor.
|
||||||
|
|
@ -1442,10 +1483,10 @@ func (f *PF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild the filter ruleset without the matching rule(s). GetRules collapses an
|
// Rebuild the filter ruleset without the matching rule(s). A FamilyAny target
|
||||||
// IPv4/IPv6 twin into one FamilyAny rule, so removing that read-back rule must
|
// must clear every row it covers — an unpinned dual-family row and any
|
||||||
// clear both underlying anchor rows; match with EqualForRemoval so a concrete-
|
// family-pinned twins added one at a time — so match with EqualForRemoval; a
|
||||||
// family target still removes only its own family and never the twin's row.
|
// concrete-family target still removes only its own family, never the twin's row.
|
||||||
kept := make([]string, 0, len(filterRaw))
|
kept := make([]string, 0, len(filterRaw))
|
||||||
removed := false
|
removed := false
|
||||||
var reAdd *Rule
|
var reAdd *Rule
|
||||||
|
|
@ -1656,11 +1697,10 @@ func (f *PF) translationBoundary(data []string) int {
|
||||||
return len(data)
|
return len(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureNATAnchors inserts them at that boundary. pf.conf sections are strictly
|
// ensureNATAnchors makes sure pf.conf carries the nat-anchor and rdr-anchor
|
||||||
// ordered options → normalization → queueing → translation → filtering, so the
|
// references for our anchor, inserting any missing ones at the translation
|
||||||
// queueing keywords (altq/queue) are deliberately NOT included: they precede the
|
// boundary (see translationBoundary), and ensures the filter anchor first via
|
||||||
// translation section, and treating one as the boundary would splice the nat/rdr
|
// ensureAnchor.
|
||||||
// anchors ahead of the queueing statements, which pfctl -f rejects.
|
|
||||||
func (f *PF) ensureNATAnchors(ctx context.Context) error {
|
func (f *PF) ensureNATAnchors(ctx context.Context) error {
|
||||||
if f.natEnsured {
|
if f.natEnsured {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -1861,13 +1901,24 @@ func (f *PF) isMissingTableErr(err error) bool {
|
||||||
return err != nil && strings.Contains(err.Error(), "does not exist")
|
return err != nil && strings.Contains(err.Error(), "does not exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
// tableFamily infers a table's family from its entries (defaulting to IPv4).
|
// tableFamily infers a table's family from its entries: IPv6 when every entry
|
||||||
|
// is IPv6, FamilyAny when the table mixes families (a pf table legitimately
|
||||||
|
// holds both), and IPv4 otherwise.
|
||||||
func (f *PF) tableFamily(entries []string) Family {
|
func (f *PF) tableFamily(entries []string) Family {
|
||||||
|
v4, v6 := false, false
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if strings.Contains(e, ":") {
|
if strings.Contains(e, ":") {
|
||||||
return IPv6
|
v6 = true
|
||||||
|
} else {
|
||||||
|
v4 = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
switch {
|
||||||
|
case v4 && v6:
|
||||||
|
return FamilyAny
|
||||||
|
case v6:
|
||||||
|
return IPv6
|
||||||
|
}
|
||||||
return IPv4
|
return IPv4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1896,7 +1947,10 @@ func (f *PF) getAddressSet(ctx context.Context, name string) (*AddressSet, error
|
||||||
func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
||||||
out, err := runCommand(ctx, "pfctl", "-s", "Tables")
|
out, err := runCommand(ctx, "pfctl", "-s", "Tables")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil
|
// A failed listing must surface: reporting "no sets" would let a Backup
|
||||||
|
// silently capture zero tables and a later Restore load rules whose
|
||||||
|
// <table> references resolve to nothing.
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
var result []*AddressSet
|
var result []*AddressSet
|
||||||
for _, line := range out {
|
for _, line := range out {
|
||||||
|
|
@ -1905,7 +1959,10 @@ func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
set, err := f.getAddressSet(ctx, name)
|
set, err := f.getAddressSet(ctx, name)
|
||||||
if err != nil || set == nil {
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if set == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, set)
|
result = append(result, set)
|
||||||
|
|
@ -1933,14 +1990,11 @@ func (f *PF) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
||||||
if err := f.ensureAnchor(ctx); err != nil {
|
if err := f.ensureAnchor(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(set.Entries) == 0 {
|
// `-T replace` reconciles an existing table to exactly the given entries
|
||||||
// pf does not lazily create tables, so a later filter rule referencing
|
// (where `-T add` would only union), and with none creates the table empty —
|
||||||
// <name> would fail to load. `-T replace` with no addresses creates the
|
// pf does not lazily create tables, so a later filter rule referencing
|
||||||
// table empty (or empties an existing one) so it exists for the caller.
|
// <name> would otherwise fail to load.
|
||||||
_, err := runCommand(ctx, "pfctl", "-t", set.Name, "-T", "replace")
|
args := []string{"-t", set.Name, "-T", "replace"}
|
||||||
return err
|
|
||||||
}
|
|
||||||
args := []string{"-t", set.Name, "-T", "add"}
|
|
||||||
args = append(args, set.Entries...)
|
args = append(args, set.Entries...)
|
||||||
_, err := runCommand(ctx, "pfctl", args...)
|
_, err := runCommand(ctx, "pfctl", args...)
|
||||||
return err
|
return err
|
||||||
|
|
@ -2031,13 +2085,11 @@ func (f *PF) Restore(ctx context.Context, zoneName string, backup *Backup) error
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
filterLines := make([]string, 0, len(backup.Rules))
|
// A portable backup may carry DirAny or TCPUDP rules captured from a backend
|
||||||
for _, r := range backup.Rules {
|
// that stores them as one row; expand exactly as the add paths do.
|
||||||
line, err := f.MarshalRule(r)
|
filterLines, err := f.marshalExpanded(backup.Rules)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
filterLines = append(filterLines, line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
natLines := make([]string, 0, len(backup.NATRules))
|
natLines := make([]string, 0, len(backup.NATRules))
|
||||||
|
|
@ -2120,20 +2172,9 @@ func (f *PF) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Ru
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var filterRaw []string
|
filterRaw, err := f.marshalExpanded(rules)
|
||||||
for _, top := range rules {
|
if err != nil {
|
||||||
// A DirAny rule fans out into an inbound rule plus its swapped outbound rule,
|
return err
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return f.loadAnchor(ctx, natRaw, filterRaw)
|
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, 1, next)
|
||||||
require.Equal(t, "!", tokens[0])
|
require.Equal(t, "!", tokens[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A verbose listing prints a counters continuation under every rule, including an
|
||||||
|
// unmodeled one held as an opaque nil row; attaching those counters must not
|
||||||
|
// dereference the nil slot.
|
||||||
|
func TestPFVerboseCountersAfterUnmodeledRow(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
out := []string{
|
||||||
|
"pass in quick inet proto tcp from any to any port > 1023 keep state",
|
||||||
|
" [ Evaluations: 100 Packets: 40 Bytes: 2400 States: 2 ]",
|
||||||
|
"pass in quick inet proto udp from any to any port = 53 keep state",
|
||||||
|
" [ Evaluations: 5 Packets: 5 Bytes: 300 States: 0 ]",
|
||||||
|
}
|
||||||
|
rules, raw := fw.parseAnchorRules(out)
|
||||||
|
require.Len(t, rules, 2)
|
||||||
|
require.Len(t, raw, 2)
|
||||||
|
require.Nil(t, rules[0], "the unmodeled line stays an opaque row")
|
||||||
|
require.NotNil(t, rules[1])
|
||||||
|
require.EqualValues(t, 5, rules[1].Packets, "counters still attach to the modeled rule")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A portable backup may carry DirAny or TCPUDP rules captured from a backend that
|
||||||
|
// stores them as one row; marshalExpanded (behind Restore and ReplaceRulesBatch)
|
||||||
|
// must fan them out exactly as the add paths do rather than fail or drop a half.
|
||||||
|
func TestPFMarshalExpandedMergedRules(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
merged := &Rule{Direction: DirAny, Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}
|
||||||
|
lines, err := fw.marshalExpanded([]*Rule{merged})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, lines, 4, "DirAny x TCPUDP must expand to four rows")
|
||||||
|
var in, out, tcp, udp int
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.Contains(l, "pass in ") {
|
||||||
|
in++
|
||||||
|
}
|
||||||
|
if strings.Contains(l, "pass out ") {
|
||||||
|
out++
|
||||||
|
}
|
||||||
|
if strings.Contains(l, "proto tcp") {
|
||||||
|
tcp++
|
||||||
|
}
|
||||||
|
if strings.Contains(l, "proto udp") {
|
||||||
|
udp++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Equal(t, 2, in)
|
||||||
|
require.Equal(t, 2, out)
|
||||||
|
require.Equal(t, 2, tcp)
|
||||||
|
require.Equal(t, 2, udp)
|
||||||
|
|
||||||
|
// A concrete rule passes through unchanged.
|
||||||
|
lines, err = fw.marshalExpanded([]*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, lines, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nat source-port match and a dynamic-target port pool have no faithful model;
|
||||||
|
// both must error so the line is preserved as an opaque row rather than mis-read.
|
||||||
|
func TestPFNATParserKeepsUnmodeledShapesOpaque(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
opaque := []string{
|
||||||
|
"nat on em0 inet from 10.0.0.0/8 port = 500 to any -> 192.0.2.1",
|
||||||
|
"nat on em0 inet from 10.0.0.0/8 to any -> (em0) port 1024:65535",
|
||||||
|
}
|
||||||
|
for _, line := range opaque {
|
||||||
|
_, err := fw.UnmarshalNATRule(line)
|
||||||
|
require.Error(t, err, "line must stay opaque: %s", line)
|
||||||
|
}
|
||||||
|
// The plain forms still parse.
|
||||||
|
r, err := fw.UnmarshalNATRule("nat on em0 inet from 10.0.0.0/8 to any -> (em0)")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, Masquerade, r.Kind)
|
||||||
|
r, err = fw.UnmarshalNATRule("rdr on em0 inet proto tcp from any to any port = 8080 -> 10.0.0.5 port 80")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, DNAT, r.Kind)
|
||||||
|
require.EqualValues(t, 8080, r.Port)
|
||||||
|
require.EqualValues(t, 80, r.ToPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableFamily types a table by its entries; a mixed table is FamilyAny, not IPv6.
|
||||||
|
func TestPFTableFamilyMixed(t *testing.T) {
|
||||||
|
fw := new(PF)
|
||||||
|
require.Equal(t, IPv4, fw.tableFamily([]string{"192.0.2.1", "198.51.100.0/24"}))
|
||||||
|
require.Equal(t, IPv6, fw.tableFamily([]string{"2001:db8::1"}))
|
||||||
|
require.Equal(t, FamilyAny, fw.tableFamily([]string{"192.0.2.1", "2001:db8::1"}))
|
||||||
|
require.Equal(t, IPv4, fw.tableFamily(nil))
|
||||||
|
}
|
||||||
|
|
|
||||||
42
services.go
42
services.go
|
|
@ -10,7 +10,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
dbus "github.com/coreos/go-systemd/dbus"
|
dbus "github.com/coreos/go-systemd/v22/dbus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// systemdActive reports whether systemd is the running init (/run/systemd/system
|
// systemdActive reports whether systemd is the running init (/run/systemd/system
|
||||||
|
|
@ -27,9 +27,11 @@ func commandExists(name string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// systemdUnitEnabled reports whether systemd reports name's unit as set to
|
// systemdUnitEnabled reports whether systemd reports name's unit as set to
|
||||||
// start at boot. "enabled", "enabled-runtime", and "generated" all count;
|
// start at boot. Only "enabled" and "enabled-runtime" count: a "generated"
|
||||||
// "generated" is how systemd wraps a SysV init.d script that was registered to
|
// unit wraps a SysV init.d script and exists for every script regardless of
|
||||||
// start. Returns false when systemd is not running or the unit is absent.
|
// its rc registration, so boot enablement for those is resolved through the
|
||||||
|
// SysV checks instead. Returns false when systemd is not running or the unit
|
||||||
|
// is absent.
|
||||||
func systemdUnitEnabled(ctx context.Context, name string) bool {
|
func systemdUnitEnabled(ctx context.Context, name string) bool {
|
||||||
conn, err := dbus.NewWithContext(ctx)
|
conn, err := dbus.NewWithContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -41,7 +43,7 @@ func systemdUnitEnabled(ctx context.Context, name string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch prop.Value.Value() {
|
switch prop.Value.Value() {
|
||||||
case "enabled", "enabled-runtime", "generated":
|
case "enabled", "enabled-runtime":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
@ -120,7 +122,9 @@ func openrcOn(name string) bool {
|
||||||
|
|
||||||
// rcLocalOn reports whether name is invoked from an rc.local file, apf's
|
// rcLocalOn reports whether name is invoked from an rc.local file, apf's
|
||||||
// last-resort enablement when neither systemd nor an init.d registration
|
// last-resort enablement when neither systemd nor an init.d registration
|
||||||
// applies. Commented lines are ignored.
|
// applies. Commented lines are ignored, and name must appear as its own token
|
||||||
|
// (bare or path-qualified) so an unrelated mention such as a log-file path
|
||||||
|
// does not count.
|
||||||
func rcLocalOn(name string) bool {
|
func rcLocalOn(name string) bool {
|
||||||
for _, p := range []string{"/etc/rc.local", "/etc/rc.d/rc.local"} {
|
for _, p := range []string{"/etc/rc.local", "/etc/rc.d/rc.local"} {
|
||||||
data, err := os.ReadFile(p)
|
data, err := os.ReadFile(p)
|
||||||
|
|
@ -132,8 +136,10 @@ func rcLocalOn(name string) bool {
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.Contains(line, name) {
|
for _, tok := range strings.Fields(line) {
|
||||||
return true
|
if tok == name || strings.HasSuffix(tok, "/"+name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -200,10 +206,17 @@ func enableService(ctx context.Context, name string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch state {
|
switch state {
|
||||||
case "enabled", "enabled-runtime", "static", "generated":
|
case "enabled", "enabled-runtime", "static":
|
||||||
// static units have no install info and are pulled in by other
|
// Static units have no install info and are pulled in by other
|
||||||
// units, so there is nothing to enable.
|
// units, so there is nothing to enable.
|
||||||
return nil
|
return nil
|
||||||
|
case "generated":
|
||||||
|
// A generated unit wraps a SysV init.d script whose enablement
|
||||||
|
// lives in its rc registration, not the unit file. `systemctl
|
||||||
|
// enable` reaches that registration through systemd-sysv-install.
|
||||||
|
if sysvServiceEnabled(ctx, name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := runCommand(ctx, "systemctl", "daemon-reload"); err != nil {
|
if _, err := runCommand(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||||
return fmt.Errorf("failed to reload systemd for %s: %s", name, err)
|
return fmt.Errorf("failed to reload systemd for %s: %s", name, err)
|
||||||
|
|
@ -244,6 +257,15 @@ func enableService(ctx context.Context, name string) error {
|
||||||
func restartService(ctx context.Context, name string) error {
|
func restartService(ctx context.Context, name string) error {
|
||||||
if systemdActive() {
|
if systemdActive() {
|
||||||
if _, err := runCommand(ctx, "systemctl", "restart", name+".service"); err != nil {
|
if _, err := runCommand(ctx, "systemctl", "restart", name+".service"); err != nil {
|
||||||
|
// A burst of restarts (a caller reconciling several times in quick
|
||||||
|
// succession) trips systemd's start rate limit and the unit lands in
|
||||||
|
// a failed start-limit-hit state; clear it and retry once rather
|
||||||
|
// than fail a reload the unit itself is healthy for.
|
||||||
|
if _, rerr := runCommand(ctx, "systemctl", "reset-failed", name+".service"); rerr == nil {
|
||||||
|
if _, err2 := runCommand(ctx, "systemctl", "restart", name+".service"); err2 == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return fmt.Errorf("failed to restart %s: %s", name, err)
|
return fmt.Errorf("failed to restart %s: %s", name, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
209
ufw_linux.go
209
ufw_linux.go
|
|
@ -21,11 +21,12 @@ const (
|
||||||
// UFWDefaults is ufw's defaults file, where the default-policy keys
|
// UFWDefaults is ufw's defaults file, where the default-policy keys
|
||||||
// (DEFAULT_INPUT_POLICY, ...) actually live — not ufw.conf.
|
// (DEFAULT_INPUT_POLICY, ...) actually live — not ufw.conf.
|
||||||
UFWDefaults = "/etc/default/ufw"
|
UFWDefaults = "/etc/default/ufw"
|
||||||
// The iptables rules files hold rules that run before/after the user rules,
|
// UFWBefore and its peers are the iptables rules files holding rules that run
|
||||||
// in iptables-restore format with ufw's own chains. They are the raw-iptables
|
// before/after the user rules, in iptables-restore format with ufw's own
|
||||||
// fallback for what ufw's tuple format cannot express — ICMP, SCTP, state
|
// chains. They are the raw-iptables fallback for what ufw's tuple format
|
||||||
// matches, custom log prefixes, rate/connection limits, and NAT (written into
|
// cannot express — ICMP, SCTP, state matches, custom log prefixes,
|
||||||
// before.rules' nat table via natHelper).
|
// rate/connection limits, and NAT (written into before.rules' nat table via
|
||||||
|
// natHelper).
|
||||||
UFWBefore = "/etc/ufw/before.rules"
|
UFWBefore = "/etc/ufw/before.rules"
|
||||||
UFWBefore6 = "/etc/ufw/before6.rules"
|
UFWBefore6 = "/etc/ufw/before6.rules"
|
||||||
UFWAfter = "/etc/ufw/after.rules"
|
UFWAfter = "/etc/ufw/after.rules"
|
||||||
|
|
@ -56,7 +57,7 @@ func NewUFW(ctx context.Context, rulePrefix string) (*UFW, error) {
|
||||||
// Try and read the ufw config file.
|
// Try and read the ufw config file.
|
||||||
fd, err := os.Open(UFWConf)
|
fd, err := os.Open(UFWConf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ufw config is not readable")
|
return nil, fmt.Errorf("ufw config is not readable: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan file for the enabled state.
|
// Scan file for the enabled state.
|
||||||
|
|
@ -126,21 +127,25 @@ func (f *UFW) Type() string {
|
||||||
// Capabilities reports the features this backend supports.
|
// Capabilities reports the features this backend supports.
|
||||||
func (f *UFW) Capabilities() Capabilities {
|
func (f *UFW) Capabilities() Capabilities {
|
||||||
return Capabilities{
|
return Capabilities{
|
||||||
Output: true,
|
Output: true,
|
||||||
Forward: true,
|
Forward: true,
|
||||||
ICMPv6: true,
|
ICMPv6: true,
|
||||||
PortList: true,
|
PortList: true,
|
||||||
ConnState: true,
|
PortPair: true,
|
||||||
InterfaceMatch: true,
|
ConnState: true,
|
||||||
Logging: true,
|
InterfaceMatch: true,
|
||||||
RateLimit: true,
|
Logging: true,
|
||||||
ConnLimit: true,
|
RateLimit: true,
|
||||||
NAT: true,
|
ConnLimit: true,
|
||||||
RuleOrdering: true,
|
NAT: true,
|
||||||
DefaultPolicy: true,
|
RuleOrdering: true,
|
||||||
RuleCounters: false,
|
DefaultPolicy: true,
|
||||||
AddressSets: true,
|
RuleCounters: false,
|
||||||
Comments: true,
|
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
|
// and sapp are just the profile's name, informational labels this library has no
|
||||||
// field for, so they are parsed to locate the trailing direction field and then
|
// field for, so they are parsed to locate the trailing direction field and then
|
||||||
// discarded; the rule decodes exactly like an ordinary 7-token tuple otherwise.
|
// discarded; the rule decodes exactly like an ordinary 7-token tuple otherwise.
|
||||||
// An 8-token tuple never occurs in a real ufw file (ufw always writes both dapp
|
// An 8-token tuple is ufw's old storage format (six core fields plus dapp/sapp
|
||||||
// and sapp, using "-" for whichever is absent) and is rejected as malformed.
|
// with no direction field), which this library does not model; it is rejected so
|
||||||
|
// the row stays opaque and is preserved verbatim.
|
||||||
func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
|
func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
|
||||||
r = &Rule{
|
r = &Rule{
|
||||||
Family: family,
|
Family: family,
|
||||||
|
|
@ -271,9 +277,10 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
n := len(tokens)
|
n := len(tokens)
|
||||||
// A route rule binding both interfaces adds a second trailing interface token,
|
// A dual-interface route rule stays at seven tokens — ufw joins both
|
||||||
// so it alone may reach eight tokens; every other tuple with eight is malformed.
|
// interfaces into one trailing "!"-separated token (`in_eth0!out_eth1`) — so
|
||||||
if n < 6 || n > 9 || (n == 8 && !forward) {
|
// eight tokens only occurs in ufw's unmodeled old format.
|
||||||
|
if n < 6 || n > 9 || n == 8 {
|
||||||
return nil, fmt.Errorf("invalid rule length")
|
return nil, fmt.Errorf("invalid rule length")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,42 +316,48 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
|
||||||
return nil, fmt.Errorf("unsupported action: %s", tokens[0])
|
return nil, fmt.Errorf("unsupported action: %s", tokens[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// The trailing token(s) after the six core fields carry the direction and any
|
// The trailing token after the six core fields carries the direction and any
|
||||||
// interface binding. An ordinary rule has one such token (`in`, `out`, or an
|
// interface binding: `in`, `out`, an interface-bound `in_eth0`/`out_eth0`, or
|
||||||
// interface-bound `in_eth0`/`out_eth0`); a route rule binding both interfaces
|
// — on a route rule binding both interfaces — ufw's "!"-joined pair
|
||||||
// has two (`in_eth0 out_eth1`). An application-profile tuple (n==9) carries the
|
// `in_eth0!out_eth1`, still one token. An application-profile tuple (n==9)
|
||||||
// dapp/sapp labels in tokens 6-7 and the direction in token 8. A 6-token tuple
|
// carries the dapp/sapp labels in tokens 6-7 and the direction in token 8. A
|
||||||
// omits the field and defaults to inbound. For a route rule the direction stays
|
// 6-token tuple omits the field and defaults to inbound. For a route rule the
|
||||||
// forward and the interfaces populate InInterface/OutInterface; for an ordinary
|
// direction stays forward and the interfaces populate InInterface/
|
||||||
// rule the single token fixes the in/out direction and its interface.
|
// OutInterface; for an ordinary rule the token fixes the in/out direction and
|
||||||
var dirToks []string
|
// its interface.
|
||||||
|
var dirTok string
|
||||||
switch n {
|
switch n {
|
||||||
case 7:
|
case 7:
|
||||||
dirToks = tokens[6:7]
|
dirTok = tokens[6]
|
||||||
case 8:
|
|
||||||
dirToks = tokens[6:8]
|
|
||||||
case 9:
|
case 9:
|
||||||
dirToks = tokens[8:9]
|
dirTok = tokens[8]
|
||||||
}
|
}
|
||||||
for _, tok := range dirToks {
|
if dirTok != "" {
|
||||||
name, iface, hasIface := strings.Cut(tok, "_")
|
parts := strings.SplitN(dirTok, "!", 2)
|
||||||
switch name {
|
if len(parts) == 2 && !forward {
|
||||||
case "in":
|
// Only a route rule carries both interfaces in one token.
|
||||||
if !forward {
|
return nil, fmt.Errorf("unsupported direction: %s", dirTok)
|
||||||
r.Direction = DirInput
|
}
|
||||||
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse rule. A tuple this backend cannot model (e.g. a route/forward rule)
|
// Parse rule. A tuple this backend cannot model (ufw's old 8-field
|
||||||
// is kept as a nil row so it still occupies a physical position.
|
// format, an application profile it cannot resolve, a malformed line) is
|
||||||
|
// kept as a nil row so it still occupies a physical position.
|
||||||
rule, err := f.UnmarshalRule(line, family)
|
rule, err := f.UnmarshalRule(line, family)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rows = append(rows, nil)
|
rows = append(rows, nil)
|
||||||
|
|
@ -653,8 +667,8 @@ func (f *UFW) MarshalRule(r *Rule) (string, error) {
|
||||||
return "", fmt.Errorf("an output interface cannot be matched on an input rule")
|
return "", fmt.Errorf("an output interface cannot be matched on an input rule")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Work on a copy as we infer the family and normalize the destination
|
// Work on a copy: the family is inferred and the destination normalized
|
||||||
// below, and we do not want to mutate the caller's rule.
|
// below, and the caller's rule must not be mutated.
|
||||||
ruleCopy := *r
|
ruleCopy := *r
|
||||||
r = &ruleCopy
|
r = &ruleCopy
|
||||||
|
|
||||||
|
|
@ -979,15 +993,25 @@ func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
// Anchor on the *filter section's COMMIT, not the file's first one: the
|
||||||
|
// canonical ufw NAT setup adds a *nat block above *filter, and a filter rule
|
||||||
|
// spliced into that block references an undeclared chain, failing the whole
|
||||||
|
// reload at iptables-restore.
|
||||||
commitIdx := -1
|
commitIdx := -1
|
||||||
|
inFilter := false
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
if strings.TrimSpace(line) == "COMMIT" {
|
t := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(t, "*") {
|
||||||
|
inFilter = t == "*filter"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inFilter && t == "COMMIT" {
|
||||||
commitIdx = i
|
commitIdx = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if commitIdx == -1 {
|
if commitIdx == -1 {
|
||||||
return false, fmt.Errorf("no COMMIT line found in %s", path)
|
return false, fmt.Errorf("no *filter COMMIT line found in %s", path)
|
||||||
}
|
}
|
||||||
out := make([]string, 0, len(lines)+len(specs))
|
out := make([]string, 0, len(lines)+len(specs))
|
||||||
out = append(out, lines[:commitIdx]...)
|
out = append(out, lines[:commitIdx]...)
|
||||||
|
|
@ -1141,9 +1165,10 @@ func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
args := f.ruleArgs(r, []string{"prepend"}, rule)
|
args := f.ruleArgs(r, []string{"prepend"}, rule)
|
||||||
// Attach a comment: a user-supplied Comment takes precedence, otherwise the
|
// Attach a comment: the prefix tag and any user-supplied Comment are combined
|
||||||
// rule is tagged with our prefix so it can be identified as ours. The comment
|
// (prefix first, see combineComment) so the rule is identifiable as ours and
|
||||||
// is only added on insert; ufw matches deletes on the rule without it.
|
// the user text still surfaces. The comment is only added on insert; ufw
|
||||||
|
// matches deletes on the rule without it.
|
||||||
if c := f.commentFor(r); c != "" {
|
if c := f.commentFor(r); c != "" {
|
||||||
args = append(args, "comment", c)
|
args = append(args, "comment", c)
|
||||||
}
|
}
|
||||||
|
|
@ -1172,11 +1197,11 @@ func (f *UFW) appendRule(ctx context.Context, r *Rule) error {
|
||||||
|
|
||||||
// nativeInsertPositionFromRows maps a 1-based logical position to ufw's 1-based
|
// nativeInsertPositionFromRows maps a 1-based logical position to ufw's 1-based
|
||||||
// native insert position, given the physical tuple rows in ufw's own order. A nil
|
// native insert position, given the physical tuple rows in ufw's own order. A nil
|
||||||
// row is a tuple ufw counts in its numbered list but this backend does not model (a
|
// row is a tuple ufw counts in its numbered list but this backend does not model
|
||||||
// route/forward rule); it still occupies a physical slot, so a route rule preceding
|
// (an old-format or otherwise unparseable tuple); it still occupies a physical
|
||||||
// the anchor shifts the native position instead of being ignored — which would
|
// slot, so such a row preceding the anchor shifts the native position instead of
|
||||||
// place the rule one slot too early per preceding route rule and, for a first-match
|
// being ignored — which would place the rule one slot too early per preceding
|
||||||
// firewall, change enforcement. Every modeled tuple is its own rule, so with no
|
// unmodeled row and, for a first-match firewall, change enforcement. Every modeled tuple is its own rule, so with no
|
||||||
// un-representable rows this reduces to the plain physical position.
|
// un-representable rows this reduces to the plain physical position.
|
||||||
func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int {
|
func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int {
|
||||||
var physPos []int
|
var physPos []int
|
||||||
|
|
@ -1198,8 +1223,8 @@ func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int {
|
||||||
|
|
||||||
// nativeInsertPosition maps a 1-based logical position (a rule's Number, as GetRules
|
// nativeInsertPosition maps a 1-based logical position (a rule's Number, as GetRules
|
||||||
// reports it) to the 1-based position ufw's own numbered list uses for `ufw insert`.
|
// reports it) to the 1-based position ufw's own numbered list uses for `ufw insert`.
|
||||||
// The two index spaces differ because ufw counts route rules this backend does not
|
// The two index spaces differ because ufw's list also counts tuples this backend
|
||||||
// model. The tuple order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's
|
// does not model. The tuple order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's
|
||||||
// native order, so the logical position's row in that list is its native position. A
|
// native order, so the logical position's row in that list is its native position. A
|
||||||
// position past the last logical rule maps past the native count, which ufw rejects
|
// position past the last logical rule maps past the native count, which ufw rejects
|
||||||
// and InsertRule then handles as a plain append.
|
// and InsertRule then handles as a plain append.
|
||||||
|
|
@ -1257,9 +1282,9 @@ func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r *
|
||||||
}
|
}
|
||||||
|
|
||||||
// position is a Number GetRules reported, which counts only the tuples this
|
// position is a Number GetRules reported, which counts only the tuples this
|
||||||
// backend models, while `ufw insert` counts ufw's own numbered list — which also
|
// backend models, while `ufw insert` counts ufw's own numbered list — which
|
||||||
// counts the route rules it does not model. Map to that native position so a
|
// also counts tuples it does not model. Map to that native position so an
|
||||||
// route rule earlier in the list does not skew the insert.
|
// unmodeled tuple earlier in the list does not skew the insert.
|
||||||
native, err := f.nativeInsertPosition(position)
|
native, err := f.nativeInsertPosition(position)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -1355,37 +1380,41 @@ func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
return f.editIPTablesRules(r, true)
|
return f.editIPTablesRules(r, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protocol-axis removal. A TCPUDP rule is backed either by a single native
|
// Protocol-axis removal. A TCPUDP rule may be backed by a single native
|
||||||
// `any`-proto tuple or by a separately-added tcp tuple + udp tuple, so resolve the
|
// `any`-proto tuple, by a separately-added tcp tuple + udp tuple, or — since
|
||||||
// actual backing before deleting.
|
// ufw treats a differing protocol as a distinct rule — by both at once, so a
|
||||||
|
// removal sweeps every backing rather than stopping at the first.
|
||||||
if onProtocolAxis(r.Proto) {
|
if onProtocolAxis(r.Proto) {
|
||||||
native, err := f.storedNativeTCPUDP(r)
|
native, err := f.storedNativeTCPUDP(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if native != nil {
|
if native != nil {
|
||||||
// Backed by a single native `any` tuple. Delete that tuple via its bare/`any`
|
// Delete the native `any` tuple via its bare/`any` form (a TCPUDP
|
||||||
// form (a TCPUDP marshal of the target). When the target names a single
|
// marshal of the target).
|
||||||
// transport, re-add the surviving opposite transport so its coverage is kept —
|
|
||||||
// the protocol analog of splitting a dual-family row on removal
|
|
||||||
// (splitDualRowProtocol returns nil for a TCPUDP target, dropping the whole row).
|
|
||||||
del := *r
|
del := *r
|
||||||
del.Proto = TCPUDP
|
del.Proto = TCPUDP
|
||||||
if err := f.deleteNative(ctx, &del); err != nil {
|
if err := f.deleteNative(ctx, &del); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if s := splitDualRowProtocol(native, r); s != nil {
|
|
||||||
return f.AddRule(ctx, zoneName, s)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
// No native tuple: the rule is backed by concrete-transport tuples. Expand a
|
// Delete the concrete-transport tuples: a TCPUDP target expands into
|
||||||
// TCPUDP target into tcp+udp and delete each; a concrete target deletes itself.
|
// tcp+udp, a concrete target deletes itself. deleteNative is a no-op for a
|
||||||
|
// tuple ufw does not hold.
|
||||||
for _, sub := range expandProtocols(r) {
|
for _, sub := range expandProtocols(r) {
|
||||||
if err := f.deleteNative(ctx, sub); err != nil {
|
if err := f.deleteNative(ctx, sub); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// When a concrete-transport target consumed a native both-transports
|
||||||
|
// tuple, re-add the surviving opposite transport so its coverage is kept —
|
||||||
|
// the protocol analog of splitting a dual-family row on removal. AddRule
|
||||||
|
// dedups, so an already-present concrete twin is not doubled.
|
||||||
|
if native != nil {
|
||||||
|
if s := splitDualRowProtocol(native, r); s != nil {
|
||||||
|
return f.AddRule(ctx, zoneName, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,8 @@ func TestUFWRules(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{`route:allow tcp 23 0.0.0.0/0 any 0.0.0.0/0 in`, Accept, 23, "", ""},
|
{`route:allow tcp 23 0.0.0.0/0 any 0.0.0.0/0 in`, Accept, 23, "", ""},
|
||||||
{`route:deny tcp 25 192.168.0.1 any 10.0.0.0/8 in_eth0`, Drop, 25, "eth0", ""},
|
{`route:deny tcp 25 192.168.0.1 any 10.0.0.0/8 in_eth0`, Drop, 25, "eth0", ""},
|
||||||
{`route:allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in_eth0 out_eth1`, Accept, 80, "eth0", "eth1"},
|
// ufw stores a dual-interface route rule as one "!"-joined trailing token.
|
||||||
|
{`route:allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in_eth0!out_eth1`, Accept, 80, "eth0", "eth1"},
|
||||||
}
|
}
|
||||||
for _, rc := range routeCases {
|
for _, rc := range routeCases {
|
||||||
got, err := fw.UnmarshalRule(rc.tuple, IPv4)
|
got, err := fw.UnmarshalRule(rc.tuple, IPv4)
|
||||||
|
|
@ -741,3 +742,46 @@ func TestUFWNativeInsertPositionSkipsUnmodeledRows(t *testing.T) {
|
||||||
require.Equal(t, 4, fw.nativeInsertPositionFromRows(withRoute, 3))
|
require.Equal(t, 4, fw.nativeInsertPositionFromRows(withRoute, 3))
|
||||||
require.Equal(t, 5, fw.nativeInsertPositionFromRows(withRoute, 4))
|
require.Equal(t, 5, fw.nativeInsertPositionFromRows(withRoute, 4))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUFWEditIPTablesRulesAnchorsFilterCommit pins that the raw-path add splices
|
||||||
|
// its rule into the *filter section's COMMIT, not the file's first COMMIT: the
|
||||||
|
// canonical ufw NAT setup puts a *nat block above *filter, and a filter rule in
|
||||||
|
// that block references an undeclared chain, failing the reload.
|
||||||
|
func TestUFWEditIPTablesRulesAnchorsFilterCommit(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
natFirst := "*nat\n:POSTROUTING ACCEPT [0:0]\n-A POSTROUTING -s 10.0.0.0/8 -o eth0 -j MASQUERADE\nCOMMIT\n" +
|
||||||
|
"*filter\n:ufw-before-input - [0:0]\nCOMMIT\n"
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "before.rules")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(natFirst), 0644))
|
||||||
|
|
||||||
|
icmp := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}
|
||||||
|
changed, err := fw.editIPTablesRulesFile(path, icmp, IPv4, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := string(data)
|
||||||
|
natEnd := strings.Index(body, "*filter")
|
||||||
|
require.NotContains(t, body[:natEnd], "--icmp-type",
|
||||||
|
"the rule must not land inside the *nat block")
|
||||||
|
require.Contains(t, body[natEnd:], "--icmp-type",
|
||||||
|
"the rule must land inside the *filter block")
|
||||||
|
// The rule sits before *filter's COMMIT.
|
||||||
|
filterPart := body[natEnd:]
|
||||||
|
require.Less(t, strings.Index(filterPart, "--icmp-type"), strings.Index(filterPart, "COMMIT"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUFWOldFormatTupleStaysOpaque pins that ufw's old 8-field storage format
|
||||||
|
// (six core fields plus dapp/sapp, no direction field) is rejected by the tuple
|
||||||
|
// parser, so parseTupleRows holds it as a nil row rather than mis-reading it.
|
||||||
|
func TestUFWOldFormatTupleStaysOpaque(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
_, err := fw.UnmarshalRule("allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 Apache -", IPv4)
|
||||||
|
require.Error(t, err, "the old 8-field format must stay opaque")
|
||||||
|
|
||||||
|
// A non-route tuple must not carry a "!"-joined interface pair.
|
||||||
|
_, err = fw.UnmarshalRule("allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in_eth0!out_eth1", IPv4)
|
||||||
|
require.Error(t, err, "a dual-interface token is only valid on a route rule")
|
||||||
|
}
|
||||||
|
|
|
||||||
44
utils.go
44
utils.go
|
|
@ -15,8 +15,28 @@ func trimQuotes(s string) string {
|
||||||
return strings.Trim(s, "\"'")
|
return strings.Trim(s, "\"'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stripUnquotedComment removes a trailing '#' comment from a config line,
|
||||||
|
// ignoring a '#' inside a quoted value so `KEY = "pre#fix"` is not truncated.
|
||||||
|
func stripUnquotedComment(line string) string {
|
||||||
|
var inQuote byte
|
||||||
|
for i := 0; i < len(line); i++ {
|
||||||
|
switch c := line[i]; {
|
||||||
|
case inQuote != 0:
|
||||||
|
if c == inQuote {
|
||||||
|
inQuote = 0
|
||||||
|
}
|
||||||
|
case c == '"' || c == '\'':
|
||||||
|
inQuote = c
|
||||||
|
case c == '#':
|
||||||
|
return line[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
// readConfValue scans a shell-style "KEY = \"VALUE\"" config file (conf.apf,
|
// readConfValue scans a shell-style "KEY = \"VALUE\"" config file (conf.apf,
|
||||||
// csf.conf) for key and returns its value, or "" if key is not set. Used for
|
// csf.conf) for key and returns its value, or "" if key is not set. The last
|
||||||
|
// assignment wins, matching how the shell sources these files. Used for
|
||||||
// one-shot flags read at construction time, not the per-rule list edits (which
|
// one-shot flags read at construction time, not the per-rule list edits (which
|
||||||
// each backend's own EditConf/EditRulePort scan handles in place).
|
// each backend's own EditConf/EditRulePort scan handles in place).
|
||||||
func readConfValue(path, key string) (string, error) {
|
func readConfValue(path, key string) (string, error) {
|
||||||
|
|
@ -25,13 +45,10 @@ func readConfValue(path, key string) (string, error) {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer func() { _ = fd.Close() }()
|
defer func() { _ = fd.Close() }()
|
||||||
|
value := ""
|
||||||
scanner := bufio.NewScanner(fd)
|
scanner := bufio.NewScanner(fd)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := strings.TrimSpace(stripUnquotedComment(scanner.Text()))
|
||||||
if ci := strings.IndexByte(line, '#'); ci >= 0 {
|
|
||||||
line = line[:ci]
|
|
||||||
}
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -40,10 +57,10 @@ func readConfValue(path, key string) (string, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(k) == key {
|
if strings.TrimSpace(k) == key {
|
||||||
return trimQuotes(strings.TrimSpace(v)), nil
|
value = trimQuotes(strings.TrimSpace(v))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", scanner.Err()
|
return value, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCommand runs command and returns its stdout and any error. The context
|
// runCommand runs command and returns its stdout and any error. The context
|
||||||
|
|
@ -146,9 +163,14 @@ func runCommandStdin(ctx context.Context, stdin string, command string, args ...
|
||||||
// Wait for the command to finish.
|
// Wait for the command to finish.
|
||||||
err = cmd.Wait()
|
err = cmd.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stderr := strings.TrimSpace(stderrData.String())
|
// Keep the underlying error wrapped so its exit code stays reachable
|
||||||
if stderr != "" {
|
// through errors.As, and only mention stdout when there is any.
|
||||||
err = fmt.Errorf("%s (stdout %s)", stderr, strings.Join(out, "\n"))
|
stderrText := strings.TrimSpace(stderrData.String())
|
||||||
|
switch {
|
||||||
|
case stderrText != "" && len(out) > 0:
|
||||||
|
err = fmt.Errorf("%s (stdout %s): %w", stderrText, strings.Join(out, "\n"), err)
|
||||||
|
case stderrText != "":
|
||||||
|
err = fmt.Errorf("%s: %w", stderrText, err)
|
||||||
}
|
}
|
||||||
// A command can fail and also produce truncated output; surface both so a
|
// A command can fail and also produce truncated output; surface both so a
|
||||||
// read error is never hidden behind the command's own failure.
|
// read error is never hidden behind the command's own failure.
|
||||||
|
|
|
||||||
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.
|
// Capabilities returns the set of features the Windows firewall backend can express.
|
||||||
func (f *WF) Capabilities() Capabilities {
|
func (f *WF) Capabilities() Capabilities {
|
||||||
return Capabilities{
|
return Capabilities{
|
||||||
Output: true,
|
Output: true,
|
||||||
ICMPv6: true,
|
ICMPv6: true,
|
||||||
PortList: true,
|
PortList: true,
|
||||||
ConnState: false,
|
PortPair: true,
|
||||||
InterfaceMatch: false,
|
// WFP has no negated address condition, no reject action (permit or
|
||||||
Logging: false,
|
// block only), and scopes a filter's family through its address
|
||||||
RateLimit: false,
|
// conditions, so an address-less rule cannot pin one family.
|
||||||
ConnLimit: false,
|
Negation: false,
|
||||||
NAT: false,
|
RejectAction: false,
|
||||||
RuleOrdering: false,
|
FamilyWithoutAddress: false,
|
||||||
DefaultPolicy: false,
|
ConnState: false,
|
||||||
RuleCounters: false,
|
InterfaceMatch: false,
|
||||||
AddressSets: false,
|
Logging: false,
|
||||||
Comments: true,
|
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()
|
newAddr = ip.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returned the parsed address.
|
// Return the parsed address.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle single IP
|
// Handle a single IP.
|
||||||
ip := net.ParseIP(addr)
|
ip := net.ParseIP(addr)
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
err = fmt.Errorf("invalid IP")
|
err = fmt.Errorf("invalid IP")
|
||||||
|
|
@ -234,6 +241,17 @@ func (f *WF) UnmarshallFWRule(fr wapi.FWRule) *Rule {
|
||||||
if it := strings.TrimSpace(fr.InterfaceTypes); it != "" && !strings.EqualFold(it, "All") {
|
if it := strings.TrimSpace(fr.InterfaceTypes); it != "" && !strings.EqualFold(it, "All") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// A disabled rule enforces nothing: decoding it as live would let AddRule's
|
||||||
|
// idempotency check treat a disabled twin as satisfying the add (the rule is
|
||||||
|
// then never enforced) and a Backup capture a rule Restore re-adds enabled.
|
||||||
|
if !fr.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The model has no edge-traversal field; decoding such a rule identically to
|
||||||
|
// its non-edge twin would let a rewrite silently narrow it.
|
||||||
|
if fr.EdgeTraversal {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Map direction.
|
// Map direction.
|
||||||
if fr.Direction == wapi.NET_FW_RULE_DIR_OUT {
|
if fr.Direction == wapi.NET_FW_RULE_DIR_OUT {
|
||||||
|
|
@ -343,8 +361,8 @@ func (f *WF) UnmarshallFWRule(fr wapi.FWRule) *Rule {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Based on direction, map the source and destination address.
|
// Based on direction, map the source and destination address: the rule model
|
||||||
// Our rule uses source/destination where as windows uses local/remote.
|
// uses source/destination, whereas Windows uses local/remote.
|
||||||
var srcRaw, dstRaw string
|
var srcRaw, dstRaw string
|
||||||
if r.IsOutput() {
|
if r.IsOutput() {
|
||||||
srcRaw = fr.LocalAddresses
|
srcRaw = fr.LocalAddresses
|
||||||
|
|
@ -391,7 +409,7 @@ func (f *WF) hasPrefix(fr wapi.FWRule) bool {
|
||||||
|
|
||||||
// profileFilter maps a zone name to the single Windows profile bit that GetRules
|
// profileFilter maps a zone name to the single Windows profile bit that GetRules
|
||||||
// and RemoveRule filter on, so both scope to the same rules. ok is false when the
|
// and RemoveRule filter on, so both scope to the same rules. ok is false when the
|
||||||
// zone names no specific profile, meaning every profile is in scope (matching an
|
// zone names no specific profile, meaning every profile is in scope.
|
||||||
func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) {
|
func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) {
|
||||||
switch {
|
switch {
|
||||||
case strings.EqualFold(zoneName, "public"):
|
case strings.EqualFold(zoneName, "public"):
|
||||||
|
|
@ -405,6 +423,14 @@ func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// profileMatches reports whether a rule's Profiles bitmask is in scope for a
|
// profileMatches reports whether a rule's Profiles bitmask is in scope for a
|
||||||
|
// zone's profile filter (every rule is, when the zone named no profile). A rule
|
||||||
|
// matches only when its Profiles exactly equals the single filter bit: AddRule
|
||||||
|
// always stores a named zone's rule under exactly one profile bit or the
|
||||||
|
// all-profiles default (never a combination), so an exact-equality test is what
|
||||||
|
// keeps a specific zone's rules disjoint from another zone's and from an
|
||||||
|
// all-profiles rule. Testing overlap instead (fr.Profiles&profile != 0) would
|
||||||
|
// let a single-zone query and, worse, a single-zone RemoveRule/Sync match and
|
||||||
|
// delete an all-profiles rule — silently affecting every other zone too.
|
||||||
func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool {
|
func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool {
|
||||||
if !useFilter {
|
if !useFilter {
|
||||||
return true
|
return true
|
||||||
|
|
@ -472,11 +498,17 @@ func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
|
||||||
if r.IsForward() {
|
if r.IsForward() {
|
||||||
return nil, unsupportedForward("windows firewall")
|
return nil, unsupportedForward("windows firewall")
|
||||||
}
|
}
|
||||||
|
// A WFP filter likewise carries one direction; a DirAny rule at this row
|
||||||
|
// level means AddRule/RemoveRule's fan-out was skipped, so reject it rather
|
||||||
|
// than silently narrow it to inbound (IsOutput is false for DirAny).
|
||||||
|
if r.Direction == DirAny {
|
||||||
|
return nil, fmt.Errorf("a both-directions rule must be expanded before marshalling")
|
||||||
|
}
|
||||||
// Windows Filtering Platform cannot match a port without a concrete
|
// Windows Filtering Platform cannot match a port without a concrete
|
||||||
// protocol; dropping the port would silently widen the rule to match all
|
// protocol; dropping the port would silently widen the rule to match all
|
||||||
// traffic, so reject it instead.
|
// traffic, so reject it instead.
|
||||||
if r.PortNeedsConcreteProtocol() {
|
if r.PortNeedsConcreteProtocol() {
|
||||||
return nil, fmt.Errorf("a port requires a tcp, udp or sctp protocol")
|
return nil, fmt.Errorf("a port requires a tcp or udp protocol")
|
||||||
}
|
}
|
||||||
// Windows only matches ports for TCP and UDP; a port on any other protocol
|
// Windows only matches ports for TCP and UDP; a port on any other protocol
|
||||||
// (e.g. SCTP) has no representation, so reject rather than silently drop it.
|
// (e.g. SCTP) has no representation, so reject rather than silently drop it.
|
||||||
|
|
@ -655,20 +687,32 @@ func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
|
||||||
|
|
||||||
fwRule.Name = strings.Join(nameParts, " ")
|
fwRule.Name = strings.Join(nameParts, " ")
|
||||||
|
|
||||||
// Set the grouping
|
// Set the grouping.
|
||||||
fwRule.Grouping = f.rulePrefix
|
fwRule.Grouping = f.rulePrefix
|
||||||
|
|
||||||
return fwRule, nil
|
return fwRule, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRule that stored the rule under the all-profiles default). A rule matches only
|
// rejectLogAndLimit reports a logging or rate/connection-limit request on a
|
||||||
// when its Profiles exactly equals this single bit: AddRule always stores a named
|
// backend whose per-rule model cannot express it. Such a rule is rejected
|
||||||
// zone's rule under exactly one profile bit or the all-profiles default (never a
|
// rather than applied without the modifier. It returns nil when the rule asks
|
||||||
// combination), so an exact-equality test is what keeps a specific zone's rules
|
// for none of these. backend names the backend for the error message. WFP is
|
||||||
// disjoint from another zone's and from an all-profiles rule. Testing overlap
|
// the only such backend, so the helper lives here beside its caller.
|
||||||
// instead (fr.Profiles&profile != 0) would let a single-zone query and, worse, a
|
func (r *Rule) rejectLogAndLimit(backend string) error {
|
||||||
// single-zone RemoveRule/Sync match and delete an all-profiles rule — silently
|
switch {
|
||||||
// affecting every other zone too.
|
case r.Log:
|
||||||
|
return fmt.Errorf("%s does not support per-rule logging in this model: %w", backend, ErrUnsupportedLog)
|
||||||
|
case r.RateLimit != nil:
|
||||||
|
return fmt.Errorf("%s does not support rate limiting in this model: %w", backend, ErrUnsupportedRateLimit)
|
||||||
|
case r.ConnLimit != nil:
|
||||||
|
return fmt.Errorf("%s does not support connection limiting in this model: %w", backend, ErrUnsupportedConnLimit)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRule adds a filter rule scoped to the zone's Windows profile (or the
|
||||||
|
// all-profiles default for an unnamed zone). It is idempotent: an equivalent
|
||||||
|
// existing rule makes it a no-op.
|
||||||
func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -801,11 +845,19 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
// zoneName names a single zone.
|
// zoneName names a single zone.
|
||||||
filterProfile, useFilter := f.profileFilter(zoneName)
|
filterProfile, useFilter := f.profileFilter(zoneName)
|
||||||
|
|
||||||
// Delete every matching rule. EqualBase ignores the IP family because Windows
|
// Windows deletes by name and removes the FIRST rule bearing it, so a name
|
||||||
// records a concrete family on the rule it lists back even when the added rule
|
// shared with a rule this removal did not match (a per-profile twin of a
|
||||||
// left it unset (mirroring GetRules). Removal is idempotent, matching the other
|
// built-in rule, say) could destroy the wrong rule. Count each name's holders
|
||||||
// backends: a rule that is not present is not an error.
|
// and how many of them this removal matches: when every holder matches,
|
||||||
|
// repeated by-name deletes are safe; otherwise the delete is ambiguous and is
|
||||||
|
// refused rather than risked.
|
||||||
|
nameTotal := map[string]int{}
|
||||||
|
nameMatched := map[string]int{}
|
||||||
|
type target struct{ name string }
|
||||||
|
var targets []target
|
||||||
for _, fr := range fwRules {
|
for _, fr := range fwRules {
|
||||||
|
nameTotal[fr.Name]++
|
||||||
|
|
||||||
// Skip rules outside the target profile, matching GetRules' filter.
|
// Skip rules outside the target profile, matching GetRules' filter.
|
||||||
if !f.profileMatches(fr.Profiles, filterProfile, useFilter) {
|
if !f.profileMatches(fr.Profiles, filterProfile, useFilter) {
|
||||||
continue
|
continue
|
||||||
|
|
@ -817,14 +869,25 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EqualBase ignores the IP family because Windows records a concrete
|
||||||
|
// family on the rule it lists back even when the added rule left it unset
|
||||||
|
// (mirroring GetRules). Removal is idempotent, matching the other
|
||||||
|
// backends: a rule that is not present is not an error.
|
||||||
if r.EqualBase(rule, true) {
|
if r.EqualBase(rule, true) {
|
||||||
ok, err := wapi.FirewallRuleDelete(fr.Name)
|
nameMatched[fr.Name]++
|
||||||
if err != nil {
|
targets = append(targets, target{fr.Name})
|
||||||
return fmt.Errorf("failed to delete rule %q: %w", fr.Name, err)
|
}
|
||||||
}
|
}
|
||||||
if !ok {
|
for _, t := range targets {
|
||||||
return fmt.Errorf("failed to delete rule %q: reported failure", fr.Name)
|
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())
|
return unsupportedSet(f.Type())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup captures the current filter rules managed by this backend.
|
// Backup captures every decodable filter rule in the zone, foreign rules
|
||||||
|
// included; the library manages the actual firewall state.
|
||||||
func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
||||||
rules, err := f.GetRules(ctx, zoneName)
|
rules, err := f.GetRules(ctx, zoneName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -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