Skip to content

Commit

Permalink
Filter interfaces by their associated IPs (#111)
Browse files Browse the repository at this point in the history
* ip interface filter implementation

* add ipv6 ip interface filter test cases

* updated comments and docs

* modify logic to allow subnets for InterfaceIPs and updated docs and tests to reflect it

* go mod tidy and go mod vendor

* update packets agent to use new interface filter interface

* added error if both INTERFACES/EXCLUDE_INTERFACES and INTERFACE_IPS are specified

* allow INTERFACE_IPS to be used for the packet agent

* factor out IPsFromInterface function since we have it duplicated in two places now
  • Loading branch information
masonj5n authored Oct 9, 2023
1 parent ca897d5 commit 1c411e1
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 44 deletions.
4 changes: 4 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ The following environment variables are available to configure the NetObserv eBF
excluded from flow tracing. It takes priority over `INTERFACES` values.
If an entry is enclosed by slashes (e.g. `/br-/`), it will match as regular expression,
otherwise it will be matched as a case-sensitive string.
* `INTERFACE_IPS` (optional) Comma-separated list of IPs/Subnets in CIDR notation (i.e. 192.0.2.0/24).
Any interface with an associated IP address within the given ranges will be listened on. This is an
alternative to specifying `INTERFACES`, useful when you know ahead of time what IP or IP range an
interface will have but not the OS-assigned interface name itself. Exclusive with INTERFACES/EXCLUDE_INTERFACES.
* `SAMPLING` (default: disabled). Rate at which packets should be sampled and sent to the target
collector. E.g. if set to 10, one out of 10 packets, on average, will be sent to the target
collector.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ require (
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 // indirect
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect
golang.org/x/net v0.13.0 // indirect
golang.org/x/oauth2 v0.10.0 // indirect
golang.org/x/term v0.10.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 h1:Jvc7gsqn21cJHCmAWx0LiimpP18LZmUxkT5Mp7EZ1mI=
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
Expand Down
34 changes: 28 additions & 6 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ type Flows struct {

// input data providers
interfaces ifaces.Informer
filter interfaceFilter
filter InterfaceFilter
ebpf ebpfFlowFetcher

// processing nodes to be wired in the buildAndStartPipeline method
Expand Down Expand Up @@ -176,10 +176,27 @@ func flowsAgent(cfg *Config,
exporter node.TerminalFunc[[]*flow.Record],
agentIP net.IP,
) (*Flows, error) {
// configure allow/deny interfaces filter
filter, err := initInterfaceFilter(cfg.Interfaces, cfg.ExcludeInterfaces)
if err != nil {
return nil, fmt.Errorf("configuring interface filters: %w", err)
var filter InterfaceFilter

switch {
case len(cfg.InterfaceIPs) > 0 && (len(cfg.Interfaces) > 0 || len(cfg.ExcludeInterfaces) > 0):
return nil, fmt.Errorf("INTERFACES/EXCLUDE_INTERFACES and INTERFACE_IPS are mutually exclusive")

case len(cfg.InterfaceIPs) > 0:
// configure ip interface filter
f, err := initIPInterfaceFilter(cfg.InterfaceIPs, IPsFromInterface)
if err != nil {
return nil, fmt.Errorf("configuring interface ip filter: %w", err)
}
filter = &f

default:
// configure allow/deny regexp interfaces filter
f, err := initRegexpInterfaceFilter(cfg.Interfaces, cfg.ExcludeInterfaces)
if err != nil {
return nil, fmt.Errorf("configuring interface filters: %w", err)
}
filter = &f
}

registerer := ifaces.NewRegisterer(informer, cfg.BuffersLength)
Expand Down Expand Up @@ -415,7 +432,12 @@ func (f *Flows) buildAndStartPipeline(ctx context.Context) (*node.Terminal[[]*fl

func (f *Flows) onInterfaceAdded(iface ifaces.Interface) {
// ignore interfaces that do not match the user configuration acceptance/exclusion lists
if !f.filter.Allowed(iface.Name) {
allowed, err := f.filter.Allowed(iface.Name)
if err != nil {
alog.WithField("interface", iface).Errorf("encountered error determining if interface is allowed: %v", err)
return
}
if !allowed {
alog.WithField("interface", iface).
Debug("interface does not match the allow/exclusion filters. Ignoring")
return
Expand Down
4 changes: 4 additions & 0 deletions pkg/agent/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ type Config struct {
// BuffersLength establishes the length of communication channels between the different processing
// stages
BuffersLength int `env:"BUFFERS_LENGTH" envDefault:"50"`
// InterfaceIPs is a list of CIDR-notation IPs/Subnets where any interface containing an IP in the given ranges
// should be listened on. This allows users to specify interfaces without knowing the OS-assigned interface names.
// Exclusive with Interfaces/ExcludeInterfaces.
InterfaceIPs []string `env:"INTERFACE_IPS" envSeparator:","`
// ExporterBufferLength establishes the length of the buffer of flow batches (not individual flows)
// that can be accumulated before the Kafka or GRPC exporter. When this buffer is full (e.g.
// because the Kafka or GRPC endpoint is slow), incoming flow batches will be dropped. If unset,
Expand Down
89 changes: 80 additions & 9 deletions pkg/agent/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,95 @@ package agent

import (
"fmt"
"net"
"net/netip"
"regexp"
"strings"
)

type interfaceFilter struct {
type InterfaceFilter interface {
Allowed(iface string) (bool, error)
}

type ipInterfaceFilter struct {
allowedIPs []netip.Prefix
// Almost always going to be a wrapper around getting
// the interface from net.InterfaceByName and then calling
// .Addrs() on the interface
ipsFromIface func(ifaceName string) ([]netip.Addr, error)
}

// Default function for getting the list of IPs configured
// for a specific network interface
func IPsFromInterface(ifaceName string) ([]netip.Addr, error) {
iface, err := net.InterfaceByName(ifaceName)
if err != nil {
return []netip.Addr{}, fmt.Errorf("error retrieving interface by name: %w", err)
}
addrs, err := iface.Addrs()
if err != nil {
return []netip.Addr{}, fmt.Errorf("error retrieving addresses from interface: %w", err)
}

interfaceAddrs := []netip.Addr{}
for _, addr := range addrs {
prefix, err := netip.ParsePrefix(addr.String())
if err != nil {
return []netip.Addr{}, fmt.Errorf("parsing given ip to netip.Addr: %w", err)
}
interfaceAddrs = append(interfaceAddrs, prefix.Addr())
}
return interfaceAddrs, nil
}

// initIPInterfaceFilter allows filtering network interfaces that are accepted/excluded by the user,
// according to the provided INTERFACE_IPS from the configuration. It allows interfaces where at least
// one of the provided CIDRs are associated with it.
func initIPInterfaceFilter(ips []string, ipsFromIface func(ifaceName string) ([]netip.Addr, error)) (ipInterfaceFilter, error) {
ipIfaceFilter := ipInterfaceFilter{}
ipIfaceFilter.ipsFromIface = ipsFromIface

for _, ip := range ips {
prefix, err := netip.ParsePrefix(ip)
if err != nil {
return ipInterfaceFilter{}, fmt.Errorf("error parsing given ip: %s: %w", ip, err)
}
ipIfaceFilter.allowedIPs = append(ipIfaceFilter.allowedIPs, prefix)
}

return ipIfaceFilter, nil
}

func (f *ipInterfaceFilter) Allowed(iface string) (bool, error) {
ifaceAddrs, err := f.ipsFromIface(iface)
if err != nil {
return false, fmt.Errorf("error calling ipsFromIface(): %w", err)
}

for _, ifaceAddr := range ifaceAddrs {
for _, allowedPrefix := range f.allowedIPs {
if allowedPrefix.Contains(ifaceAddr) {
return true, nil
}
}
}
return false, nil
}

type regexpInterfaceFilter struct {
allowedRegexpes []*regexp.Regexp
allowedMatches []string
excludedRegexpes []*regexp.Regexp
excludedMatches []string
}

// initInterfaceFilter allows filtering network interfaces that are accepted/excluded by the user,
// initRegexpInterfaceFilter allows filtering network interfaces that are accepted/excluded by the user,
// according to the provided allowed and excluded interfaces from the configuration. It allows
// matching by exact string or by regular expression
func initInterfaceFilter(allowed, excluded []string) (interfaceFilter, error) {
func initRegexpInterfaceFilter(allowed, excluded []string) (regexpInterfaceFilter, error) {
var isRegexp = regexp.MustCompile("^/(.*)/$")

itf := interfaceFilter{}
itf := regexpInterfaceFilter{}
for _, definition := range allowed {
definition = strings.Trim(definition, " ")
// the user defined a /regexp/ between slashes: compile and store it as regular expression
Expand Down Expand Up @@ -53,7 +124,7 @@ func initInterfaceFilter(allowed, excluded []string) (interfaceFilter, error) {
return itf, nil
}

func (itf *interfaceFilter) Allowed(name string) bool {
func (itf *regexpInterfaceFilter) Allowed(name string) (bool, error) {
// if the allowed list is empty, any interface is allowed except if it matches the exclusion list
allowed := len(itf.allowedRegexpes)+len(itf.allowedMatches) == 0
// otherwise, we check if it appears in the allowed lists (both exact match and regexp)
Expand All @@ -64,18 +135,18 @@ func (itf *interfaceFilter) Allowed(name string) bool {
allowed = allowed || itf.allowedRegexpes[i].MatchString(string(name))
}
if !allowed {
return false
return false, nil
}
// if the interface matches the allow lists, we still need to check that is not excluded
for _, match := range itf.excludedMatches {
if name == match {
return false
return false, nil
}
}
for _, re := range itf.excludedRegexpes {
if re.MatchString(string(name)) {
return false
return false, nil
}
}
return true
return true, nil
}
104 changes: 85 additions & 19 deletions pkg/agent/filter_test.go
Original file line number Diff line number Diff line change
@@ -1,43 +1,109 @@
package agent

import (
"net/netip"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestInterfaces_DefaultConfig(t *testing.T) {
ifaces, err := initInterfaceFilter(nil, []string{"lo"})
ifaces, err := initRegexpInterfaceFilter(nil, []string{"lo"})
require.NoError(t, err)

assert.True(t, ifaces.Allowed("eth0"))
assert.True(t, ifaces.Allowed("br-0"))
assert.False(t, ifaces.Allowed("lo"))
// Allowed
for _, iface := range []string{"eth0", "br-0"} {
iface := iface
allowed, err := ifaces.Allowed(iface)
require.NoError(t, err)
assert.True(t, allowed)
}

// Not Allowed
allowed, err := ifaces.Allowed("lo")
require.NoError(t, err)
assert.False(t, allowed)
}

func TestInterfaceFilter_SelectingInterfaces_DefaultExclusion(t *testing.T) {
ifaces, err := initInterfaceFilter([]string{"eth0", "/^br-/"}, []string{"lo"})
ifaces, err := initRegexpInterfaceFilter([]string{"eth0", "/^br-/"}, []string{"lo"})
require.NoError(t, err)

assert.True(t, ifaces.Allowed("eth0"))
assert.True(t, ifaces.Allowed("br-0"))
assert.False(t, ifaces.Allowed("eth01"))
assert.False(t, ifaces.Allowed("abr-3"))
assert.False(t, ifaces.Allowed("lo"))
// Allowed
for _, iface := range []string{"eth0", "br-0"} {
iface := iface
allowed, err := ifaces.Allowed(iface)
require.NoError(t, err)
assert.True(t, allowed)
}
// Not Allowed
for _, iface := range []string{"eth01", "abr-3", "lo"} {
iface := iface
allowed, err := ifaces.Allowed(iface)
require.NoError(t, err)
assert.False(t, allowed)
}
}

func TestInterfaceFilter_ExclusionTakesPriority(t *testing.T) {
ifaces, err := initRegexpInterfaceFilter([]string{"/^eth/", "/^br-/"}, []string{"eth1", "/^br-1/"})
require.NoError(t, err)

// Allowed
for _, iface := range []string{"eth0", "eth-10", "eth11", "br-2", "br-0"} {
iface := iface
allowed, err := ifaces.Allowed(iface)
require.NoError(t, err)
assert.True(t, allowed)
}
// Not Allowed
for _, iface := range []string{"eth1", "br-1", "br-10"} {
iface := iface
allowed, err := ifaces.Allowed(iface)
require.NoError(t, err)
assert.False(t, allowed)
}
}

func TestInterfaceFilter_InterfaceIPs(t *testing.T) {
mockIPByIface := func(iface string) ([]netip.Addr, error) {
switch iface {
case "eth0":
return []netip.Addr{netip.MustParsePrefix("198.51.100.1/24").Addr()}, nil

case "eth1":
return []netip.Addr{netip.MustParsePrefix("198.51.100.2/24").Addr()}, nil

case "eth2":
return []netip.Addr{netip.MustParsePrefix("2001:db8::1/32").Addr(), netip.MustParsePrefix("198.51.100.3/24").Addr()}, nil

case "eth3":
return []netip.Addr{netip.MustParsePrefix("2001:db8::2/32").Addr()}, nil

case "eth4":
return []netip.Addr{netip.MustParsePrefix("192.0.2.120/24").Addr()}, nil

default:
panic("unexpected interface name")
}
}

ifaces, err := initInterfaceFilter([]string{"/^eth/", "/^br-/"}, []string{"eth1", "/^br-1/"})
ifaces, err := initIPInterfaceFilter([]string{"198.51.100.1/32", "2001:db8::1/128", "192.0.2.0/24"}, mockIPByIface)
require.NoError(t, err)

assert.True(t, ifaces.Allowed("eth0"))
assert.True(t, ifaces.Allowed("eth10"))
assert.True(t, ifaces.Allowed("eth11"))
assert.True(t, ifaces.Allowed("br-2"))
assert.True(t, ifaces.Allowed("br-0"))
assert.False(t, ifaces.Allowed("eth1"))
assert.False(t, ifaces.Allowed("br-1"))
assert.False(t, ifaces.Allowed("br-10"))
// Allowed
for _, iface := range []string{"eth0", "eth2", "eth4"} {
iface := iface
allowed, err := ifaces.Allowed(iface)
require.NoError(t, err)
assert.True(t, allowed)
}
// Not Allowed
for _, iface := range []string{"eth1", "eth3"} {
iface := iface
allowed, err := ifaces.Allowed(iface)
require.NoError(t, err)
assert.False(t, allowed)
}
}
Loading

0 comments on commit 1c411e1

Please sign in to comment.