diff --git a/docs/config.md b/docs/config.md index 860ff5e95..d62f25d95 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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. diff --git a/go.mod b/go.mod index 818da8b54..f78eecffd 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index ba4a2d25f..9e0712c2e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 710a49f8f..0612c2aa3 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -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 @@ -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) @@ -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 diff --git a/pkg/agent/config.go b/pkg/agent/config.go index 4c2e68c8d..fc07a6232 100644 --- a/pkg/agent/config.go +++ b/pkg/agent/config.go @@ -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, diff --git a/pkg/agent/filter.go b/pkg/agent/filter.go index 140b8c1a4..366bcd25d 100644 --- a/pkg/agent/filter.go +++ b/pkg/agent/filter.go @@ -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 @@ -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) @@ -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 } diff --git a/pkg/agent/filter_test.go b/pkg/agent/filter_test.go index c74b7d5ad..93d2450f2 100644 --- a/pkg/agent/filter_test.go +++ b/pkg/agent/filter_test.go @@ -1,6 +1,7 @@ package agent import ( + "net/netip" "testing" "github.com/stretchr/testify/assert" @@ -8,36 +9,101 @@ import ( ) 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) + } } diff --git a/pkg/agent/packets_agent.go b/pkg/agent/packets_agent.go index bab423f92..a8525a654 100644 --- a/pkg/agent/packets_agent.go +++ b/pkg/agent/packets_agent.go @@ -20,7 +20,7 @@ type Packets struct { // input data providers interfaces ifaces.Informer - filter interfaceFilter + filter InterfaceFilter ebpf ebpfPacketFetcher // processing nodes to be wired in the buildAndStartPipeline method @@ -79,10 +79,27 @@ func packetsAgent(cfg *Config, packetexporter node.TerminalFunc[[]*flow.PacketRecord], agentIP net.IP, ) (*Packets, 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) @@ -205,7 +222,12 @@ func (p *Packets) buildAndStartPipeline(ctx context.Context) (*node.Terminal[[]* func (p *Packets) onInterfaceAdded(iface ifaces.Interface) { // ignore interfaces that do not match the user configuration acceptance/exclusion lists - if !p.filter.Allowed(iface.Name) { + allowed, err := p.filter.Allowed(iface.Name) + if err != nil { + plog.WithField("[PCA]interface", iface).WithError(err). + Warn("couldn't determine if interface is allowed. Ignoring") + } + if !allowed { plog.WithField("interface", iface). Debug("[PCA]interface does not match the allow/exclusion filters. Ignoring") return diff --git a/vendor/modules.txt b/vendor/modules.txt index 50df5a4d1..5228a5c24 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -316,7 +316,7 @@ golang.org/x/crypto/cryptobyte golang.org/x/crypto/cryptobyte/asn1 golang.org/x/crypto/curve25519 golang.org/x/crypto/curve25519/internal/field -# golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 +# golang.org/x/exp v0.0.0-20230321023759-10a507213a29 ## explicit; go 1.18 golang.org/x/exp/constraints golang.org/x/exp/maps