mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 08:59:08 +02:00
[client] Unify peer and route ACL filtering with multi-source rules (#6322)
* Unify peer and route ACL filtering with multi-source peer rules * Remove partial userspace firewall mode and open foreign chains via a table-less allower * Snapshot iptables rule maps before persisting state * Scope userspace firewall wildcard source rules per address family * Install nftables peer filter and mangle rules in a single transaction * Share the iptables jump rule spec between install and cleanup * Fix legacy ACL source wildcard and keep rollback tracking on delete failure * Fix CI: recognize multi-value port set lookups in tests and correct PeerIP lint suppression * Fall back to per-prefix filter rules when ipset is unavailable * Annotate legacy PeerIP usages in ACL tests and fix import formatting * Keep firewall rule bookkeeping in step with the kernel on replace and teardown * Release the routing reference when the route manager shuts down * Keep set references and rule tracking consistent when a routing rule fails
This commit is contained in:
+360
-191
@@ -1,8 +1,6 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
@@ -24,6 +22,10 @@ import (
|
||||
|
||||
var ErrSourceRangesEmpty = errors.New("sources range is empty")
|
||||
|
||||
// ErrNoRuleReturned is returned when the firewall backend reports success
|
||||
// from AddFilterRule but yields no rule to track.
|
||||
var ErrNoRuleReturned = errors.New("backend returned no rule")
|
||||
|
||||
// Manager is a ACL rules manager
|
||||
type Manager interface {
|
||||
ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool)
|
||||
@@ -32,19 +34,48 @@ type Manager interface {
|
||||
// DefaultManager uses firewall manager to handle
|
||||
type DefaultManager struct {
|
||||
firewall firewall.Manager
|
||||
ipsetCounter int
|
||||
peerRulesPairs map[id.RuleID][]firewall.Rule
|
||||
routeRules map[id.RuleID]struct{}
|
||||
routeRules map[id.RuleID]firewall.Rule
|
||||
previousConfigHash uint64
|
||||
hasAppliedConfig bool
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// peerRuleGroup collapses a set of single-source FirewallRules sharing
|
||||
// the same selector into one multi-source rule to push to the backend.
|
||||
type peerRuleGroup struct {
|
||||
direction mgmProto.RuleDirection
|
||||
action mgmProto.RuleAction
|
||||
protocol mgmProto.RuleProtocol
|
||||
port *mgmProto.PortInfo
|
||||
// legacyPort is used only when PortInfo is empty (old management).
|
||||
legacyPort string
|
||||
policyID []byte
|
||||
sources []netip.Prefix
|
||||
}
|
||||
|
||||
// peerRuleKey is the comparable selector that decides which single-source
|
||||
// rules merge into one group. Rules with an equal key collapse into one
|
||||
// multi-source backend rule. PortInfo is flattened into its scalar fields
|
||||
// so the key compares by value; policyID keeps policies separate so two
|
||||
// policies authorizing different peers don't merge under one attribution.
|
||||
type peerRuleKey struct {
|
||||
v6 bool
|
||||
policyID string
|
||||
direction mgmProto.RuleDirection
|
||||
action mgmProto.RuleAction
|
||||
protocol mgmProto.RuleProtocol
|
||||
legacyPort string
|
||||
port uint16
|
||||
rangeStart uint16
|
||||
rangeEnd uint16
|
||||
}
|
||||
|
||||
func NewDefaultManager(fm firewall.Manager) *DefaultManager {
|
||||
return &DefaultManager{
|
||||
firewall: fm,
|
||||
peerRulesPairs: make(map[id.RuleID][]firewall.Rule),
|
||||
routeRules: make(map[id.RuleID]struct{}),
|
||||
routeRules: make(map[id.RuleID]firewall.Rule),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +119,14 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
|
||||
time.Since(start), total)
|
||||
}()
|
||||
|
||||
d.applyPeerACLs(networkMap)
|
||||
peerErr := d.applyPeerACLs(networkMap)
|
||||
if peerErr != nil {
|
||||
log.Errorf("apply peer ACLs: %v", peerErr)
|
||||
}
|
||||
|
||||
routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag)
|
||||
if routeErr != nil {
|
||||
log.Errorf("Failed to apply route ACLs: %v", routeErr)
|
||||
log.Errorf("apply route ACLs: %v", routeErr)
|
||||
}
|
||||
|
||||
flushErr := d.firewall.Flush()
|
||||
@@ -104,7 +138,7 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
|
||||
// If applying or flushing failed, leave the previous hash untouched so the
|
||||
// next (possibly identical) update is not skipped and gets a chance to
|
||||
// reconcile the firewall state.
|
||||
if err == nil && routeErr == nil && flushErr == nil {
|
||||
if err == nil && peerErr == nil && routeErr == nil && flushErr == nil {
|
||||
d.previousConfigHash = hash
|
||||
d.hasAppliedConfig = true
|
||||
} else {
|
||||
@@ -135,7 +169,7 @@ func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dns
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) {
|
||||
func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) error {
|
||||
rules := networkMap.FirewallRules
|
||||
|
||||
// if we got empty rules list but management not set networkMap.FirewallRulesIsEmpty flag
|
||||
@@ -158,59 +192,167 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) {
|
||||
)
|
||||
}
|
||||
|
||||
newRulePairs := make(map[id.RuleID][]firewall.Rule)
|
||||
ipsetByRuleSelectors := make(map[string]string)
|
||||
// Group incoming single-source rules from management by their
|
||||
// (direction, action, proto, port) selector and merge sources.
|
||||
// One call to the firewall backend per merged rule.
|
||||
// A deny we cannot decode would leave its traffic unblocked, so skip
|
||||
// the whole pass and keep existing rules until the next sync.
|
||||
groups, denyErr, err := groupPeerRules(rules)
|
||||
if denyErr != nil {
|
||||
return fmt.Errorf("decode deny rule sources: %w", denyErr)
|
||||
}
|
||||
|
||||
// TODO: deny rules should be fatal: if a deny rule fails to apply, we must
|
||||
// roll back all allow rules to avoid a fail-open where allowed traffic bypasses
|
||||
// the missing deny. Currently we accumulate errors and continue.
|
||||
newRulePairs := make(map[id.RuleID][]firewall.Rule)
|
||||
var merr *multierror.Error
|
||||
for _, r := range rules {
|
||||
// if this rule is member of rule selection with more than DefaultIPsCountForSet
|
||||
// it's IP address can be used in the ipset for firewall manager which supports it
|
||||
selector := d.getRuleGroupingSelector(r)
|
||||
ipsetName, ok := ipsetByRuleSelectors[selector]
|
||||
if !ok {
|
||||
d.ipsetCounter++
|
||||
ipsetName = fmt.Sprintf("nb%07d", d.ipsetCounter)
|
||||
ipsetByRuleSelectors[selector] = ipsetName
|
||||
}
|
||||
pairID, rulePair, err := d.protoRuleToFirewallRule(r, ipsetName)
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("apply firewall rule: %w", err))
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, err)
|
||||
}
|
||||
|
||||
// Apply denies first. A deny that fails to install is a security
|
||||
// failure (fail-open), so if any deny errors we roll back the
|
||||
// denies we already installed in this pass and bail out without
|
||||
// installing any accept. Pre-existing rules stay untouched until
|
||||
// the next successful pass clears them.
|
||||
denies, accepts := splitDenyAccept(groups)
|
||||
if err := d.installPeerGroups(denies, newRulePairs, true); err != nil {
|
||||
return fmt.Errorf("install deny rules: %w", err)
|
||||
}
|
||||
|
||||
if err := d.installPeerGroups(accepts, newRulePairs, false); err != nil {
|
||||
merr = multierror.Append(merr, err)
|
||||
}
|
||||
|
||||
// Tear down rules that disappeared from the networkmap. Any rule
|
||||
// the backend refuses to delete stays in our tracking so it gets
|
||||
// retried on the next ApplyFiltering. Otherwise a transient
|
||||
// delete failure would leak the rule in the firewall until the
|
||||
// process exits.
|
||||
for pairID, rules := range d.peerRulesPairs {
|
||||
if _, ok := newRulePairs[pairID]; ok {
|
||||
continue
|
||||
}
|
||||
if len(rulePair) > 0 {
|
||||
d.peerRulesPairs[pairID] = rulePair
|
||||
newRulePairs[pairID] = rulePair
|
||||
}
|
||||
}
|
||||
|
||||
if merr != nil {
|
||||
log.Errorf("failed to apply %d peer ACL rule(s): %v", merr.Len(), nberrors.FormatErrorOrNil(merr))
|
||||
}
|
||||
|
||||
for pairID, rules := range d.peerRulesPairs {
|
||||
if _, ok := newRulePairs[pairID]; !ok {
|
||||
for _, rule := range rules {
|
||||
if err := d.firewall.DeletePeerRule(rule); err != nil {
|
||||
log.Errorf("failed to delete peer firewall rule: %v", err)
|
||||
continue
|
||||
}
|
||||
var remaining []firewall.Rule
|
||||
for _, rule := range rules {
|
||||
if err := d.firewall.DeleteFilterRule(rule); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete peer rule, will retry: %w", err))
|
||||
remaining = append(remaining, rule)
|
||||
}
|
||||
delete(d.peerRulesPairs, pairID)
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
newRulePairs[pairID] = remaining
|
||||
}
|
||||
}
|
||||
d.peerRulesPairs = newRulePairs
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
// installPeerGroups applies each group and records the resulting rule
|
||||
// pairs in newRulePairs. With atomic set (deny rules), a single failure
|
||||
// rolls back every rule installed in this call and returns, leaving the
|
||||
// firewall exactly as before: denies are fail-closed and must be applied
|
||||
// all-or-nothing. With atomic unset (accept rules), failures are
|
||||
// accumulated and the remaining groups still install, so one malformed
|
||||
// allow cannot drop every other legitimate allow in the pass.
|
||||
func (d *DefaultManager) installPeerGroups(groups []*peerRuleGroup, newRulePairs map[id.RuleID][]firewall.Rule, atomic bool) error {
|
||||
var freshlyInstalled []id.RuleID
|
||||
var merr *multierror.Error
|
||||
for _, g := range groups {
|
||||
pairID, rulePair, err := d.applyPeerGroup(g)
|
||||
if err != nil {
|
||||
if atomic {
|
||||
d.rollbackInstalled(freshlyInstalled)
|
||||
return fmt.Errorf("apply firewall rule: %w", err)
|
||||
}
|
||||
merr = multierror.Append(merr, fmt.Errorf("apply firewall rule: %w", err))
|
||||
continue
|
||||
}
|
||||
if len(rulePair) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, existed := d.peerRulesPairs[pairID]; !existed {
|
||||
freshlyInstalled = append(freshlyInstalled, pairID)
|
||||
}
|
||||
d.peerRulesPairs[pairID] = rulePair
|
||||
newRulePairs[pairID] = rulePair
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func (d *DefaultManager) rollbackInstalled(pairIDs []id.RuleID) {
|
||||
var merr *multierror.Error
|
||||
for _, pairID := range pairIDs {
|
||||
// Keep any rule the backend refuses to delete tracked so it is
|
||||
// retried on the next ApplyFiltering instead of leaking in the
|
||||
// firewall with no tracking left to remove it.
|
||||
var remaining []firewall.Rule
|
||||
for _, rule := range d.peerRulesPairs[pairID] {
|
||||
if err := d.firewall.DeleteFilterRule(rule); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("rule %s: %w", pairID, err))
|
||||
remaining = append(remaining, rule)
|
||||
}
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
d.peerRulesPairs[pairID] = remaining
|
||||
} else {
|
||||
delete(d.peerRulesPairs, pairID)
|
||||
}
|
||||
}
|
||||
if err := nberrors.FormatErrorOrNil(merr); err != nil {
|
||||
log.Errorf("rollback peer rules: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DefaultManager) applyPeerGroup(g *peerRuleGroup) (id.RuleID, []firewall.Rule, error) {
|
||||
protocol, err := ConvertToFirewallProtocol(g.protocol)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("skipping firewall rule: %w", err)
|
||||
}
|
||||
action, err := convertFirewallAction(g.action)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("skipping firewall rule: %w", err)
|
||||
}
|
||||
port, err := resolveGroupPort(g)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var fwRule firewall.Rule
|
||||
switch g.direction {
|
||||
case mgmProto.RuleDirection_IN:
|
||||
fwRule, err = d.firewall.AddFilterRule(g.policyID, g.sources, firewall.Network{}, protocol, nil, port, action)
|
||||
case mgmProto.RuleDirection_OUT:
|
||||
if d.firewall.IsStateful() {
|
||||
return "", nil, nil
|
||||
}
|
||||
if shouldSkipInvertedRule(protocol, port) {
|
||||
return "", nil, nil
|
||||
}
|
||||
fwRule, err = d.firewall.AddFilterRule(g.policyID, g.sources, firewall.Network{}, protocol, port, nil, action)
|
||||
default:
|
||||
return "", nil, errors.New("invalid direction")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("add firewall rule: %w", err)
|
||||
}
|
||||
if fwRule == nil {
|
||||
return "", nil, fmt.Errorf("add firewall rule: %w", ErrNoRuleReturned)
|
||||
}
|
||||
|
||||
// Derive the pair id from the backend rule, like the route path:
|
||||
// the backend dedups identical content, so two policies authorizing
|
||||
// the same flow resolve to the same id and a single backing rule.
|
||||
return fwRule.ID(), []firewall.Rule{fwRule}, nil
|
||||
}
|
||||
|
||||
func (d *DefaultManager) applyRouteACLs(rules []*mgmProto.RouteFirewallRule, dynamicResolver bool) error {
|
||||
newRouteRules := make(map[id.RuleID]struct{}, len(rules))
|
||||
newRouteRules := make(map[id.RuleID]firewall.Rule, len(rules))
|
||||
var merr *multierror.Error
|
||||
|
||||
// Apply new rules - firewall manager will return existing rule ID if already present
|
||||
// Apply new rules - firewall manager will return the existing rule if already present
|
||||
for _, rule := range rules {
|
||||
id, err := d.applyRouteACL(rule, dynamicResolver)
|
||||
addedRule, err := d.applyRouteACL(rule, dynamicResolver)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSourceRangesEmpty) {
|
||||
log.Debugf("skipping empty sources rule with destination %s: %v", rule.Destination, err)
|
||||
@@ -219,16 +361,18 @@ func (d *DefaultManager) applyRouteACLs(rules []*mgmProto.RouteFirewallRule, dyn
|
||||
}
|
||||
continue
|
||||
}
|
||||
newRouteRules[id] = struct{}{}
|
||||
newRouteRules[addedRule.ID()] = addedRule
|
||||
}
|
||||
|
||||
// Clean up old firewall rules
|
||||
for id := range d.routeRules {
|
||||
if _, exists := newRouteRules[id]; !exists {
|
||||
if err := d.firewall.DeleteRouteRule(id); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete route rule: %w", err))
|
||||
}
|
||||
// implicitly deleted from the map
|
||||
// Tear down old route rules; retain ones the backend refused so a
|
||||
// transient failure doesn't leave orphaned rules in the firewall.
|
||||
for ruleID, rule := range d.routeRules {
|
||||
if _, exists := newRouteRules[ruleID]; exists {
|
||||
continue
|
||||
}
|
||||
if err := d.firewall.DeleteFilterRule(rule); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete route rule, will retry: %w", err))
|
||||
newRouteRules[ruleID] = rule
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,102 +380,202 @@ func (d *DefaultManager) applyRouteACLs(rules []*mgmProto.RouteFirewallRule, dyn
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func (d *DefaultManager) applyRouteACL(rule *mgmProto.RouteFirewallRule, dynamicResolver bool) (id.RuleID, error) {
|
||||
func (d *DefaultManager) applyRouteACL(rule *mgmProto.RouteFirewallRule, dynamicResolver bool) (firewall.Rule, error) {
|
||||
if len(rule.SourceRanges) == 0 {
|
||||
return "", ErrSourceRangesEmpty
|
||||
return nil, ErrSourceRangesEmpty
|
||||
}
|
||||
|
||||
var sources []netip.Prefix
|
||||
for _, sourceRange := range rule.SourceRanges {
|
||||
source, err := netip.ParsePrefix(sourceRange)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse source range: %w", err)
|
||||
return nil, fmt.Errorf("parse source range: %w", err)
|
||||
}
|
||||
sources = append(sources, source)
|
||||
sources = append(sources, firewall.UnmapPrefix(source))
|
||||
}
|
||||
|
||||
destination, err := determineDestination(rule, dynamicResolver, sources)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("determine destination: %w", err)
|
||||
return nil, fmt.Errorf("determine destination: %w", err)
|
||||
}
|
||||
|
||||
protocol, err := convertToFirewallProtocol(rule.Protocol)
|
||||
protocol, err := ConvertToFirewallProtocol(rule.Protocol)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid protocol: %w", err)
|
||||
return nil, fmt.Errorf("invalid protocol: %w", err)
|
||||
}
|
||||
|
||||
action, err := convertFirewallAction(rule.Action)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid action: %w", err)
|
||||
return nil, fmt.Errorf("invalid action: %w", err)
|
||||
}
|
||||
|
||||
dPorts := convertPortInfo(rule.PortInfo)
|
||||
|
||||
addedRule, err := d.firewall.AddRouteFiltering(rule.PolicyID, sources, destination, protocol, nil, dPorts, action)
|
||||
addedRule, err := d.firewall.AddFilterRule(rule.PolicyID, sources, destination, protocol, nil, dPorts, action)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("add route rule: %w", err)
|
||||
return nil, fmt.Errorf("add route rule: %w", err)
|
||||
}
|
||||
if addedRule == nil {
|
||||
return nil, fmt.Errorf("add route rule: %w", ErrNoRuleReturned)
|
||||
}
|
||||
|
||||
return id.RuleID(addedRule.ID()), nil
|
||||
return addedRule, nil
|
||||
}
|
||||
|
||||
func (d *DefaultManager) protoRuleToFirewallRule(
|
||||
r *mgmProto.FirewallRule,
|
||||
ipsetName string,
|
||||
) (id.RuleID, []firewall.Rule, error) {
|
||||
ip, err := extractRuleIP(r)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
// splitDenyAccept partitions groups by action so denies can be
|
||||
// applied before accepts. Order within each bucket is preserved.
|
||||
func splitDenyAccept(groups []*peerRuleGroup) (denies, accepts []*peerRuleGroup) {
|
||||
for _, g := range groups {
|
||||
if g.action == mgmProto.RuleAction_DROP {
|
||||
denies = append(denies, g)
|
||||
} else {
|
||||
accepts = append(accepts, g)
|
||||
}
|
||||
}
|
||||
return denies, accepts
|
||||
}
|
||||
|
||||
// groupPeerRules merges single-source rules sharing a selector into
|
||||
// multi-source groups. It splits source-decode failures by action:
|
||||
// denyErr is non-nil when a deny rule could not be decoded, which is a
|
||||
// fail-open risk the caller must treat as fatal for the pass; err
|
||||
// carries the tolerable accept-rule failures the caller can log and
|
||||
// continue past.
|
||||
func groupPeerRules(rules []*mgmProto.FirewallRule) (groups []*peerRuleGroup, denyErr error, err error) {
|
||||
var denyMerr, acceptMerr *multierror.Error
|
||||
byKey := make(map[peerRuleKey]*peerRuleGroup)
|
||||
order := make([]peerRuleKey, 0)
|
||||
|
||||
for _, r := range rules {
|
||||
srcs, decErr := extractRuleSources(r)
|
||||
if decErr != nil {
|
||||
if r.Action == mgmProto.RuleAction_DROP {
|
||||
denyMerr = multierror.Append(denyMerr, decErr)
|
||||
} else {
|
||||
acceptMerr = multierror.Append(acceptMerr, decErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// A single FirewallRule normally carries one address family, but
|
||||
// split by family defensively: each backend keys a rule to one
|
||||
// family and would mismatch sources of the other, so a group's
|
||||
// sources must never span families.
|
||||
v4, v6 := splitPrefixesByFamily(srcs)
|
||||
for _, sub := range []struct {
|
||||
isV6 bool
|
||||
sources []netip.Prefix
|
||||
}{{false, v4}, {true, v6}} {
|
||||
if len(sub.sources) == 0 {
|
||||
continue
|
||||
}
|
||||
key := ruleGroupKey(r, sub.isV6)
|
||||
g, ok := byKey[key]
|
||||
if !ok {
|
||||
g = &peerRuleGroup{
|
||||
direction: r.Direction,
|
||||
action: r.Action,
|
||||
protocol: r.Protocol,
|
||||
port: r.PortInfo,
|
||||
legacyPort: r.Port,
|
||||
policyID: r.PolicyID,
|
||||
}
|
||||
byKey[key] = g
|
||||
order = append(order, key)
|
||||
}
|
||||
g.sources = append(g.sources, sub.sources...)
|
||||
}
|
||||
}
|
||||
|
||||
protocol, err := convertToFirewallProtocol(r.Protocol)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("skipping firewall rule: %s", err)
|
||||
out := make([]*peerRuleGroup, 0, len(order))
|
||||
for _, k := range order {
|
||||
out = append(out, byKey[k])
|
||||
}
|
||||
return out, nberrors.FormatErrorOrNil(denyMerr), nberrors.FormatErrorOrNil(acceptMerr)
|
||||
}
|
||||
|
||||
func prefixIsV6(p netip.Prefix) bool {
|
||||
return p.Addr().Is6() && !p.Addr().Is4In6()
|
||||
}
|
||||
|
||||
// splitPrefixesByFamily partitions prefixes into IPv4 and IPv6 groups.
|
||||
func splitPrefixesByFamily(prefixes []netip.Prefix) (v4, v6 []netip.Prefix) {
|
||||
for _, p := range prefixes {
|
||||
if prefixIsV6(p) {
|
||||
v6 = append(v6, p)
|
||||
} else {
|
||||
v4 = append(v4, p)
|
||||
}
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// ruleGroupKey builds the selector key for a rule. v6 must reflect the
|
||||
// rule's source family: mgmt emits one rule per family and mixing them
|
||||
// would break ICMP-variant selection in uspfilter.
|
||||
func ruleGroupKey(r *mgmProto.FirewallRule, v6 bool) peerRuleKey {
|
||||
k := peerRuleKey{
|
||||
v6: v6,
|
||||
policyID: string(r.PolicyID),
|
||||
direction: r.Direction,
|
||||
action: r.Action,
|
||||
protocol: r.Protocol,
|
||||
legacyPort: r.Port,
|
||||
}
|
||||
if pi := r.PortInfo; pi != nil {
|
||||
k.port = uint16(pi.GetPort())
|
||||
if rng := pi.GetRange(); rng != nil {
|
||||
k.rangeStart = uint16(rng.GetStart())
|
||||
k.rangeEnd = uint16(rng.GetEnd())
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// extractRuleSources returns all source prefixes the rule applies to.
|
||||
// New management populates sourcePrefixes; older management sets PeerIP.
|
||||
func extractRuleSources(r *mgmProto.FirewallRule) ([]netip.Prefix, error) {
|
||||
if len(r.SourcePrefixes) > 0 {
|
||||
out := make([]netip.Prefix, 0, len(r.SourcePrefixes))
|
||||
for _, raw := range r.SourcePrefixes {
|
||||
addr, err := netiputil.DecodeAddr(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode source prefix: %w", err)
|
||||
}
|
||||
out = append(out, netip.PrefixFrom(addr.Unmap(), addr.Unmap().BitLen()))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
action, err := convertFirewallAction(r.Action)
|
||||
peerIP := r.PeerIP //nolint:staticcheck // PeerIP is the legacy source field for old management servers
|
||||
addr, err := netip.ParseAddr(peerIP)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("skipping firewall rule: %s", err)
|
||||
return nil, fmt.Errorf("parse peer IP %q: %w", peerIP, err)
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
// An unspecified PeerIP means "any peer" (legacy management
|
||||
// allow-all fallback); only a /0 prefix matches any source in the
|
||||
// backends, a full-length prefix would match nothing.
|
||||
if addr.IsUnspecified() {
|
||||
return []netip.Prefix{netip.PrefixFrom(addr, 0)}, nil
|
||||
}
|
||||
return []netip.Prefix{netip.PrefixFrom(addr, addr.BitLen())}, nil
|
||||
}
|
||||
|
||||
var port *firewall.Port
|
||||
if !portInfoEmpty(r.PortInfo) {
|
||||
port = convertPortInfo(r.PortInfo)
|
||||
} else if r.Port != "" {
|
||||
// old version of management, single port
|
||||
value, err := strconv.Atoi(r.Port)
|
||||
func resolveGroupPort(g *peerRuleGroup) (*firewall.Port, error) {
|
||||
if !portInfoEmpty(g.port) {
|
||||
return convertPortInfo(g.port), nil
|
||||
}
|
||||
if g.legacyPort != "" {
|
||||
value, err := strconv.ParseUint(g.legacyPort, 10, 16)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("invalid port: %w", err)
|
||||
return nil, fmt.Errorf("invalid port: %w", err)
|
||||
}
|
||||
port = &firewall.Port{
|
||||
return &firewall.Port{
|
||||
Values: []uint16{uint16(value)},
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
ruleID := d.getPeerRuleID(ip, protocol, int(r.Direction), port, action)
|
||||
if rulesPair, ok := d.peerRulesPairs[ruleID]; ok {
|
||||
return ruleID, rulesPair, nil
|
||||
}
|
||||
|
||||
var rules []firewall.Rule
|
||||
switch r.Direction {
|
||||
case mgmProto.RuleDirection_IN:
|
||||
rules, err = d.addInRules(r.PolicyID, ip, protocol, port, action, ipsetName)
|
||||
case mgmProto.RuleDirection_OUT:
|
||||
if d.firewall.IsStateful() {
|
||||
return "", nil, nil
|
||||
}
|
||||
// return traffic for outbound connections if firewall is stateless
|
||||
rules, err = d.addOutRules(r.PolicyID, ip, protocol, port, action, ipsetName)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("invalid direction, skipping firewall rule")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return ruleID, rules, nil
|
||||
// nolint:nilnil // a nil port legitimately means "no port restriction"
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func portInfoEmpty(portInfo *mgmProto.PortInfo) bool {
|
||||
@@ -350,84 +594,9 @@ func portInfoEmpty(portInfo *mgmProto.PortInfo) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DefaultManager) addInRules(
|
||||
id []byte,
|
||||
ip netip.Addr,
|
||||
protocol firewall.Protocol,
|
||||
port *firewall.Port,
|
||||
action firewall.Action,
|
||||
ipsetName string,
|
||||
) ([]firewall.Rule, error) {
|
||||
rule, err := d.firewall.AddPeerFiltering(id, ip.AsSlice(), protocol, nil, port, action, ipsetName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add firewall rule: %w", err)
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (d *DefaultManager) addOutRules(
|
||||
id []byte,
|
||||
ip netip.Addr,
|
||||
protocol firewall.Protocol,
|
||||
port *firewall.Port,
|
||||
action firewall.Action,
|
||||
ipsetName string,
|
||||
) ([]firewall.Rule, error) {
|
||||
if shouldSkipInvertedRule(protocol, port) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rule, err := d.firewall.AddPeerFiltering(id, ip.AsSlice(), protocol, port, nil, action, ipsetName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add firewall rule: %w", err)
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// getPeerRuleID returns unique ID for the rule based on its parameters.
|
||||
func (d *DefaultManager) getPeerRuleID(
|
||||
ip netip.Addr,
|
||||
proto firewall.Protocol,
|
||||
direction int,
|
||||
port *firewall.Port,
|
||||
action firewall.Action,
|
||||
) id.RuleID {
|
||||
idStr := ip.String() + string(proto) + strconv.Itoa(direction) + strconv.Itoa(int(action))
|
||||
if port != nil {
|
||||
idStr += port.String()
|
||||
}
|
||||
|
||||
return id.RuleID(hex.EncodeToString(md5.New().Sum([]byte(idStr))))
|
||||
}
|
||||
|
||||
// getRuleGroupingSelector takes all rule properties except IP address to build selector
|
||||
func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) string {
|
||||
return fmt.Sprintf("%v:%v:%v:%s:%v", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port, rule.PortInfo)
|
||||
}
|
||||
|
||||
// extractRuleIP extracts the peer IP from a firewall rule.
|
||||
// If sourcePrefixes is populated (new management), decode the first entry and use its address.
|
||||
// Otherwise fall back to the deprecated PeerIP string field (old management).
|
||||
func extractRuleIP(r *mgmProto.FirewallRule) (netip.Addr, error) {
|
||||
if len(r.SourcePrefixes) > 0 {
|
||||
addr, err := netiputil.DecodeAddr(r.SourcePrefixes[0])
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("decode source prefix: %w", err)
|
||||
}
|
||||
return addr.Unmap(), nil
|
||||
}
|
||||
|
||||
//nolint:staticcheck // PeerIP used for backward compatibility with old management
|
||||
addr, err := netip.ParseAddr(r.PeerIP)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("invalid IP address, skipping firewall rule")
|
||||
}
|
||||
return addr.Unmap(), nil
|
||||
}
|
||||
|
||||
func convertToFirewallProtocol(protocol mgmProto.RuleProtocol) (firewall.Protocol, error) {
|
||||
// ConvertToFirewallProtocol maps a management rule protocol to the
|
||||
// firewall protocol type.
|
||||
func ConvertToFirewallProtocol(protocol mgmProto.RuleProtocol) (firewall.Protocol, error) {
|
||||
switch protocol {
|
||||
case mgmProto.RuleProtocol_TCP:
|
||||
return firewall.ProtocolTCP, nil
|
||||
|
||||
Reference in New Issue
Block a user