diff --git a/pkg/containerprofilemanager/v1/container_data.go b/pkg/containerprofilemanager/v1/container_data.go index 9ddb1ed555..7f07ad5c22 100644 --- a/pkg/containerprofilemanager/v1/container_data.go +++ b/pkg/containerprofilemanager/v1/container_data.go @@ -159,7 +159,7 @@ func (cd *containerData) getCallStacks() []v1beta1.IdentifiedCallStack { } // getIngressNetworkNeighbors returns ingress network neighbors for this container -func (cd *containerData) getIngressNetworkNeighbors(namespace string, k8sClient k8sclient.K8sClientInterface, dnsResolverClient dnsmanager.DNSResolver) []v1beta1.NetworkNeighbor { +func (cd *containerData) getIngressNetworkNeighbors(containerID string, namespace string, k8sClient k8sclient.K8sClientInterface, dnsResolverClient dnsmanager.DNSResolver) []v1beta1.NetworkNeighbor { var ingress []v1beta1.NetworkNeighbor if cd.networks == nil { return ingress @@ -167,7 +167,7 @@ func (cd *containerData) getIngressNetworkNeighbors(namespace string, k8sClient for _, event := range cd.networks.ToSlice() { if event.PktType == utils.HostPktType { - neighbor := cd.createNetworkNeighbor(event, namespace, k8sClient, dnsResolverClient) + neighbor := cd.createNetworkNeighbor(containerID, event, namespace, k8sClient, dnsResolverClient) if neighbor == nil { continue } @@ -179,7 +179,7 @@ func (cd *containerData) getIngressNetworkNeighbors(namespace string, k8sClient } // getEgressNetworkNeighbors returns egress network neighbors for this container -func (cd *containerData) getEgressNetworkNeighbors(namespace string, k8sClient k8sclient.K8sClientInterface, dnsResolverClient dnsmanager.DNSResolver) []v1beta1.NetworkNeighbor { +func (cd *containerData) getEgressNetworkNeighbors(containerID string, namespace string, k8sClient k8sclient.K8sClientInterface, dnsResolverClient dnsmanager.DNSResolver) []v1beta1.NetworkNeighbor { var egress []v1beta1.NetworkNeighbor if cd.networks == nil { return egress @@ -187,7 +187,7 @@ func (cd *containerData) getEgressNetworkNeighbors(namespace string, k8sClient k for _, event := range cd.networks.ToSlice() { if event.PktType != utils.HostPktType { - neighbor := cd.createNetworkNeighbor(event, namespace, k8sClient, dnsResolverClient) + neighbor := cd.createNetworkNeighbor(containerID, event, namespace, k8sClient, dnsResolverClient) if neighbor == nil { continue } @@ -199,7 +199,7 @@ func (cd *containerData) getEgressNetworkNeighbors(namespace string, k8sClient k } // createNetworkNeighbor creates a network neighbor from a network event -func (cd *containerData) createNetworkNeighbor(networkEvent NetworkEvent, namespace string, k8sClient k8sclient.K8sClientInterface, dnsResolverClient dnsmanager.DNSResolver) *v1beta1.NetworkNeighbor { +func (cd *containerData) createNetworkNeighbor(containerID string, networkEvent NetworkEvent, namespace string, k8sClient k8sclient.K8sClientInterface, dnsResolverClient dnsmanager.DNSResolver) *v1beta1.NetworkNeighbor { var neighborEntry v1beta1.NetworkNeighbor portIdentifier := generatePortIdentifierFromEvent(networkEvent) @@ -262,7 +262,7 @@ func (cd *containerData) createNetworkNeighbor(networkEvent NetworkEvent, namesp neighborEntry.IPAddress = networkEvent.Destination.IPAddress if dnsResolverClient != nil { - domain, ok := dnsResolverClient.ResolveIPAddress(networkEvent.Destination.IPAddress) + domain, ok := dnsResolverClient.ResolveIPAddress(containerID, networkEvent.Destination.IPAddress) if ok { neighborEntry.DNS = domain neighborEntry.DNSNames = []string{domain} diff --git a/pkg/containerprofilemanager/v1/containerprofile_manager_test.go b/pkg/containerprofilemanager/v1/containerprofile_manager_test.go index c4338e421c..876919492c 100644 --- a/pkg/containerprofilemanager/v1/containerprofile_manager_test.go +++ b/pkg/containerprofilemanager/v1/containerprofile_manager_test.go @@ -562,11 +562,11 @@ func TestContainerDataMethods(t *testing.T) { assert.Empty(t, callStacks) // Test getIngressNetworkNeighbors with nil networks - ingress := cd.getIngressNetworkNeighbors("default", nil, nil) + ingress := cd.getIngressNetworkNeighbors("", "default", nil, nil) assert.Empty(t, ingress) // Test getEgressNetworkNeighbors with nil networks - egress := cd.getEgressNetworkNeighbors("default", nil, nil) + egress := cd.getEgressNetworkNeighbors("", "default", nil, nil) assert.Empty(t, egress) } diff --git a/pkg/containerprofilemanager/v1/event_reporting_test.go b/pkg/containerprofilemanager/v1/event_reporting_test.go index 2de5abf703..d948cc980d 100644 --- a/pkg/containerprofilemanager/v1/event_reporting_test.go +++ b/pkg/containerprofilemanager/v1/event_reporting_test.go @@ -17,7 +17,7 @@ import ( // createNetworkNeighbor's DNS branch without a real dnsmanager. type fakeDNSResolver struct{ domain string } -func (f fakeDNSResolver) ResolveIPAddress(string) (string, bool) { return f.domain, true } +func (f fakeDNSResolver) ResolveIPAddress(string, string) (string, bool) { return f.domain, true } func (f fakeDNSResolver) ResolveContainerProcessToCloudServices(string, uint32) mapset.Set[string] { return nil } @@ -111,7 +111,7 @@ func TestNetworkNeighborIncrementCoversMaxDNSName(t *testing.T) { } cd := &containerData{} - neighbor := cd.createNetworkNeighbor(networkEvent, "default", nil, fakeDNSResolver{domain: maxDNSName}) + neighbor := cd.createNetworkNeighbor("", networkEvent, "default", nil, fakeDNSResolver{domain: maxDNSName}) if !assert.NotNil(t, neighbor) { return } @@ -151,7 +151,7 @@ func TestNetworkNeighborIncrementCoversSelectorPayload(t *testing.T) { // neighbor. watchedContainerData.Namespace is what networkNeighborIncrement reads to make // the same "different namespace" call createNetworkNeighbor's own namespace arg does below. cd := &containerData{watchedContainerData: &objectcache.WatchedContainerData{Namespace: "default"}} - neighbor := cd.createNetworkNeighbor(networkEvent, "default", nil, nil) + neighbor := cd.createNetworkNeighbor("", networkEvent, "default", nil, nil) if !assert.NotNil(t, neighbor) { return } @@ -214,3 +214,41 @@ func TestResolveExecPath(t *testing.T) { }) } } + +type trackingDNSResolver struct { + lastContainerID string + lastIPAddress string +} + +func (r *trackingDNSResolver) ResolveIPAddress(containerID string, ipAddr string) (string, bool) { + r.lastContainerID = containerID + r.lastIPAddress = ipAddr + return "resolved.domain", true +} + +func (r *trackingDNSResolver) ResolveContainerProcessToCloudServices(string, uint32) mapset.Set[string] { + return nil +} + +func TestCreateNetworkNeighbor_EmptyContainerIDWithWatchedContainerData(t *testing.T) { + cd := &containerData{ + watchedContainerData: &objectcache.WatchedContainerData{ + ContainerID: "watched-container-456", + }, + } + + networkEvent := NetworkEvent{ + Port: 80, + PktType: utils.OutgoingPktType, + Destination: Destination{ + IPAddress: "93.184.216.34", + }, + } + + resolver := &trackingDNSResolver{} + neighbor := cd.createNetworkNeighbor("", networkEvent, "default", nil, resolver) + assert.NotNil(t, neighbor) + assert.Equal(t, "", resolver.lastContainerID, "empty containerID must be preserved without falling back to watchedContainerData") + assert.Equal(t, "93.184.216.34", resolver.lastIPAddress) + assert.Equal(t, "resolved.domain", neighbor.DNS) +} diff --git a/pkg/containerprofilemanager/v1/monitoring.go b/pkg/containerprofilemanager/v1/monitoring.go index 696787e2a8..e90757f235 100644 --- a/pkg/containerprofilemanager/v1/monitoring.go +++ b/pkg/containerprofilemanager/v1/monitoring.go @@ -207,8 +207,8 @@ func (cpm *ContainerProfileManager) saveContainerProfile(watchedContainer *objec Endpoints: containerData.getEndpoints(), PolicyByRuleId: containerData.getRulePolicies(), IdentifiedCallStacks: containerData.getCallStacks(), - Egress: containerData.getEgressNetworkNeighbors(container.K8s.Namespace, cpm.k8sClient, cpm.dnsResolverClient), - Ingress: containerData.getIngressNetworkNeighbors(container.K8s.Namespace, cpm.k8sClient, cpm.dnsResolverClient), + Egress: containerData.getEgressNetworkNeighbors(watchedContainer.ContainerID, container.K8s.Namespace, cpm.k8sClient, cpm.dnsResolverClient), + Ingress: containerData.getIngressNetworkNeighbors(watchedContainer.ContainerID, container.K8s.Namespace, cpm.k8sClient, cpm.dnsResolverClient), LabelSelector: metav1.LabelSelector{ MatchLabels: watchedContainer.ParentWorkloadSelector.MatchLabels, MatchExpressions: watchedContainer.ParentWorkloadSelector.MatchExpressions, diff --git a/pkg/dnsmanager/dns_manager.go b/pkg/dnsmanager/dns_manager.go index fdbd7ea444..d6801c1bd6 100644 --- a/pkg/dnsmanager/dns_manager.go +++ b/pkg/dnsmanager/dns_manager.go @@ -3,8 +3,10 @@ package dnsmanager import ( "net" "strings" + "sync" "time" + "github.com/armosec/armoapi-go/armotypes" mapset "github.com/deckarep/golang-set/v2" "github.com/goradd/maps" lru "github.com/hashicorp/golang-lru/v2" @@ -17,10 +19,15 @@ import ( // DNSManager is used to manage DNS events and save IP resolutions. type DNSManager struct { - addressToDomainMap *lru.Cache[string, string] - lookupCache cache.ExpiringCache // Cache for DNS lookups - failureCache cache.ExpiringCache // Cache for failed lookups - containerToCloudServices maps.SafeMap[string, *maps.SafeMap[uint32, mapset.Set[string]]] // key: containerId, value: map of pid to cloud services + cacheSize int + perContainerCacheSize int + cacheMu sync.Mutex + hostAddressToDomain *lru.Cache[string, string] + containerToAddressToDomain *lru.Cache[string, *lru.Cache[string, string]] + removedContainers *lru.Cache[string, struct{}] + lookupCache cache.ExpiringCache // Cache for DNS lookups + failureCache cache.ExpiringCache // Cache for failed lookups + containerToCloudServices maps.SafeMap[string, *maps.SafeMap[uint32, mapset.Set[string]]] // key: containerID, value: map of pid to cloud services } type cacheEntry struct { @@ -28,41 +35,152 @@ type cacheEntry struct { } const ( - defaultPositiveTTL = 1 * time.Minute // Default TTL for successful lookups - defaultNegativeTTL = 5 * time.Second // Default TTL for failed lookups - maxServiceCacheSize = 50 // Maximum number of cloud services to cache per container + defaultPositiveTTL = 1 * time.Minute // Default TTL for successful lookups + defaultNegativeTTL = 5 * time.Second // Default TTL for failed lookups + defaultDNSCacheSize = 10000 // Default total host DNS cache size when non-positive size provided + maxServiceCacheSize = 50 // Maximum number of cloud services to cache per container + defaultPerContainerCacheSize = 1000 // Default maximum number of DNS resolutions cached per container + minTrackedContainers = 10 // Minimum number of concurrent containers with DNS caches + maxRemovedContainersEntries = 10000 // Maximum number of removed containers to track to prevent resurrection + defaultRemovalGracePeriod = 10 * time.Second // Grace period before evicting resolution cache to allow terminal profile save ) var _ DNSManagerClient = (*DNSManager)(nil) var _ DNSResolver = (*DNSManager)(nil) +func isHost(containerID string) bool { + return containerID == armotypes.HostContainerID +} + func CreateDNSManager(size int) *DNSManager { - addressToDomainMap, err := lru.New[string, string](size) + if size <= 0 { + size = defaultDNSCacheSize + } + + perContainerSize := defaultPerContainerCacheSize + if size < perContainerSize { + perContainerSize = size + } + + maxContainers := size / perContainerSize + if maxContainers < minTrackedContainers { + maxContainers = minTrackedContainers + if maxContainers > size { + maxContainers = size + } + if maxContainers < 1 { + maxContainers = 1 + } + perContainerSize = size / maxContainers + if perContainerSize < 1 { + perContainerSize = 1 + } + } + + hostCacheSize := perContainerSize + if hostCacheSize < 1 { + hostCacheSize = 1 + } + + hostCache, err := lru.New[string, string](hostCacheSize) if err != nil { - logger.L().Fatal("creating lru cache", helpers.Error(err)) + logger.L().Fatal("creating host lru cache", helpers.Error(err)) + return nil + } + + containerCache, err := lru.New[string, *lru.Cache[string, string]](maxContainers) + if err != nil { + logger.L().Fatal("creating container lru cache", helpers.Error(err)) + return nil + } + + removedCache, err := lru.New[string, struct{}](maxRemovedContainersEntries) + if err != nil { + logger.L().Fatal("creating removed containers cache", helpers.Error(err)) return nil } return &DNSManager{ - addressToDomainMap: addressToDomainMap, - lookupCache: cache.NewTTL(defaultPositiveTTL, defaultPositiveTTL), - failureCache: cache.NewTTL(defaultNegativeTTL, defaultNegativeTTL), + cacheSize: size, + perContainerCacheSize: perContainerSize, + hostAddressToDomain: hostCache, + containerToAddressToDomain: containerCache, + removedContainers: removedCache, + lookupCache: cache.NewTTL(defaultPositiveTTL, defaultPositiveTTL), + failureCache: cache.NewTTL(defaultNegativeTTL, defaultNegativeTTL), + } +} + +func (dm *DNSManager) isRemoved(containerID string) bool { + return dm.removedContainers != nil && dm.removedContainers.Contains(containerID) +} + +func (dm *DNSManager) getContainerCache(containerID string) *lru.Cache[string, string] { + if isHost(containerID) { + return dm.hostAddressToDomain + } + if containerID == "" || dm.isRemoved(containerID) { + return nil + } + if cache, found := dm.containerToAddressToDomain.Get(containerID); found { + return cache + } + + dm.cacheMu.Lock() + defer dm.cacheMu.Unlock() + + if dm.isRemoved(containerID) { + return nil + } + if cache, found := dm.containerToAddressToDomain.Get(containerID); found { + return cache } + + cache, err := lru.New[string, string](dm.perContainerCacheSize) + if err != nil { + logger.L().Error("creating per-container lru cache", helpers.Error(err), helpers.String("containerID", containerID)) + return nil + } + dm.containerToAddressToDomain.Add(containerID, cache) + return cache } func (dm *DNSManager) ContainerCallback(notif containercollection.PubSubEvent) { switch notif.Type { case containercollection.EventTypeAddContainer: - dm.containerToCloudServices.Set(notif.Container.Runtime.ContainerID, maps.NewSafeMap[uint32, mapset.Set[string]]()) + containerID := notif.Container.Runtime.ContainerID + dm.cacheMu.Lock() + if dm.removedContainers != nil { + dm.removedContainers.Remove(containerID) + } + if !dm.containerToCloudServices.Has(containerID) { + dm.containerToCloudServices.Set(containerID, maps.NewSafeMap[uint32, mapset.Set[string]]()) + } + dm.cacheMu.Unlock() case containercollection.EventTypeRemoveContainer: - dm.containerToCloudServices.Delete(notif.Container.Runtime.ContainerID) + containerID := notif.Container.Runtime.ContainerID + dm.cacheMu.Lock() + if dm.removedContainers != nil { + dm.removedContainers.Add(containerID, struct{}{}) + } + dm.containerToCloudServices.Delete(containerID) + dm.cacheMu.Unlock() + + time.AfterFunc(defaultRemovalGracePeriod, func() { + dm.cacheMu.Lock() + if !dm.containerToCloudServices.Has(containerID) { + dm.containerToAddressToDomain.Remove(containerID) + } + dm.cacheMu.Unlock() + }) } } func (dm *DNSManager) ReportEvent(dnsEvent utils.DNSEvent) { dnsName := dnsEvent.GetDNSName() + containerID := dnsEvent.GetContainerID() if isCloudService(dnsName) { - if pidToServices, found := dm.containerToCloudServices.Load(dnsEvent.GetContainerID()); found { + if pidToServices, found := dm.containerToCloudServices.Load(containerID); found { // Guard against cache size getting too large by checking the cardinality per container and pid if services, found := pidToServices.Load(dnsEvent.GetPID()); found { if services.Cardinality() < maxServiceCacheSize { @@ -77,10 +195,14 @@ func (dm *DNSManager) ReportEvent(dnsEvent utils.DNSEvent) { } } + containerCache := dm.getContainerCache(containerID) + if addresses := dnsEvent.GetAddresses(); len(addresses) > 0 { - for _, address := range addresses { - if address != "" { - dm.addressToDomainMap.Add(address, dnsName) + if containerCache != nil { + for _, address := range addresses { + if address != "" { + containerCache.Add(address, dnsName) + } } } @@ -100,8 +222,12 @@ func (dm *DNSManager) ReportEvent(dnsEvent utils.DNSEvent) { if cached, found := dm.lookupCache.Get(dnsName); found { entry := cached.(cacheEntry) // Use cached addresses - for _, addr := range entry.addresses { - dm.addressToDomainMap.Add(addr, dnsName) + if containerCache != nil { + for _, addr := range entry.addresses { + if addr != "" { + containerCache.Add(addr, dnsName) + } + } } return } @@ -118,8 +244,12 @@ func (dm *DNSManager) ReportEvent(dnsEvent utils.DNSEvent) { addrStrings := make([]string, 0, len(ipAddresses)) for _, addr := range ipAddresses { addrStr := addr.String() - addrStrings = append(addrStrings, addrStr) - dm.addressToDomainMap.Add(addrStr, dnsName) + if addrStr != "" { + addrStrings = append(addrStrings, addrStr) + if containerCache != nil { + containerCache.Add(addrStr, dnsName) + } + } } // Cache the successful lookup @@ -128,13 +258,25 @@ func (dm *DNSManager) ReportEvent(dnsEvent utils.DNSEvent) { }) } -func (dm *DNSManager) ResolveIPAddress(ipAddr string) (string, bool) { - domain, found := dm.addressToDomainMap.Get(ipAddr) - return domain, found +func (dm *DNSManager) ResolveIPAddress(containerID string, ipAddr string) (string, bool) { + if isHost(containerID) { + if dm.hostAddressToDomain != nil { + return dm.hostAddressToDomain.Get(ipAddr) + } + return "", false + } + if containerID == "" { + return "", false + } + if cache, found := dm.containerToAddressToDomain.Get(containerID); found && cache != nil { + domain, found := cache.Get(ipAddr) + return domain, found + } + return "", false } -func (dm *DNSManager) ResolveContainerProcessToCloudServices(containerId string, pid uint32) mapset.Set[string] { - if pidToServices, found := dm.containerToCloudServices.Load(containerId); found { +func (dm *DNSManager) ResolveContainerProcessToCloudServices(containerID string, pid uint32) mapset.Set[string] { + if pidToServices, found := dm.containerToCloudServices.Load(containerID); found { if services, found := pidToServices.Load(pid); found { return services } diff --git a/pkg/dnsmanager/dns_manager_interface.go b/pkg/dnsmanager/dns_manager_interface.go index 66741c6d52..9249189c36 100644 --- a/pkg/dnsmanager/dns_manager_interface.go +++ b/pkg/dnsmanager/dns_manager_interface.go @@ -12,6 +12,7 @@ type DNSManagerClient interface { } type DNSResolver interface { - ResolveIPAddress(ipAddr string) (string, bool) - ResolveContainerProcessToCloudServices(containerId string, pid uint32) mapset.Set[string] + ResolveIPAddress(containerID string, ipAddr string) (string, bool) + ResolveContainerProcessToCloudServices(containerID string, pid uint32) mapset.Set[string] } + diff --git a/pkg/dnsmanager/dns_manager_mock.go b/pkg/dnsmanager/dns_manager_mock.go index 46f374205f..dc8fa269ac 100644 --- a/pkg/dnsmanager/dns_manager_mock.go +++ b/pkg/dnsmanager/dns_manager_mock.go @@ -22,7 +22,7 @@ func (n *DNSManagerMock) ReportEvent(_ utils.DNSEvent) { func (n *DNSManagerMock) ContainerCallback(_ containercollection.PubSubEvent) { } -func (n *DNSManagerMock) ResolveIPAddress(_ string) (string, bool) { +func (n *DNSManagerMock) ResolveIPAddress(_ string, _ string) (string, bool) { return "", false } diff --git a/pkg/dnsmanager/dns_manager_test.go b/pkg/dnsmanager/dns_manager_test.go index 98a6df81c6..5ca8472e8d 100644 --- a/pkg/dnsmanager/dns_manager_test.go +++ b/pkg/dnsmanager/dns_manager_test.go @@ -10,6 +10,7 @@ import ( "math/rand/v2" + "github.com/armosec/armoapi-go/armotypes" containercollection "github.com/inspektor-gadget/inspektor-gadget/pkg/container-collection" "github.com/kubescape/node-agent/pkg/utils" "github.com/stretchr/testify/assert" @@ -20,18 +21,21 @@ import ( func TestResolveIPAddress(t *testing.T) { tests := []struct { - name string - dnsEvent *utils.StructEvent - ipAddr string - want string - wantOk bool + name string + containerID string + dnsEvent *utils.StructEvent + ipAddr string + want string + wantOk bool }{ { - name: "ip found", - ipAddr: "67.225.146.248", + name: "ip found with container id", + containerID: "container-123", + ipAddr: "67.225.146.248", dnsEvent: &utils.StructEvent{ - EventType: utils.DnsEventType, - DNSName: "test.com", + EventType: utils.DnsEventType, + ContainerID: "container-123", + DNSName: "test.com", Addresses: []string{ "67.225.146.248", }, @@ -40,11 +44,13 @@ func TestResolveIPAddress(t *testing.T) { wantOk: true, }, { - name: "ip not found", - ipAddr: "67.225.146.248", + name: "ip not found", + containerID: "container-123", + ipAddr: "67.225.146.248", dnsEvent: &utils.StructEvent{ - EventType: utils.DnsEventType, - DNSName: "test.com", + EventType: utils.DnsEventType, + ContainerID: "container-123", + DNSName: "test.com", Addresses: []string{ "54.23.332.4", }, @@ -53,11 +59,43 @@ func TestResolveIPAddress(t *testing.T) { wantOk: false, }, { - name: "no address", - ipAddr: "67.225.146.248", + name: "no address", + containerID: "container-123", + ipAddr: "67.225.146.248", + dnsEvent: &utils.StructEvent{ + EventType: utils.DnsEventType, + ContainerID: "container-123", + DNSName: "test.com", + }, + want: "", + wantOk: false, + }, + { + name: "host process with HostContainerID", + containerID: armotypes.HostContainerID, + ipAddr: "1.1.1.1", dnsEvent: &utils.StructEvent{ - EventType: utils.DnsEventType, - DNSName: "test.com", + EventType: utils.DnsEventType, + ContainerID: armotypes.HostContainerID, + DNSName: "one.one.one.one", + Addresses: []string{ + "1.1.1.1", + }, + }, + want: "one.one.one.one", + wantOk: true, + }, + { + name: "empty container id returns miss", + containerID: "", + ipAddr: "1.1.1.1", + dnsEvent: &utils.StructEvent{ + EventType: utils.DnsEventType, + ContainerID: "", + DNSName: "one.one.one.one", + Addresses: []string{ + "1.1.1.1", + }, }, want: "", wantOk: false, @@ -69,7 +107,7 @@ func TestResolveIPAddress(t *testing.T) { dm := CreateDNSManager(1000) dm.ReportEvent(tt.dnsEvent) - got, ok := dm.ResolveIPAddress(tt.ipAddr) + got, ok := dm.ResolveIPAddress(tt.containerID, tt.ipAddr) assert.Equal(t, tt.want, got) assert.Equal(t, tt.wantOk, ok) }) @@ -91,8 +129,9 @@ func TestResolveIPAddressFallback(t *testing.T) { { name: "dns resolution fallback", dnsEvent: &utils.StructEvent{ - EventType: utils.DnsEventType, - DNSName: "example.com", // Using example.com as it's guaranteed to exist + EventType: utils.DnsEventType, + ContainerID: "test-container-fallback", + DNSName: "example.com", // Using example.com as it's guaranteed to exist }, want: "example.com", wantOk: true, @@ -115,7 +154,7 @@ func TestResolveIPAddressFallback(t *testing.T) { } dm.ReportEvent(tt.dnsEvent) - got, ok := dm.ResolveIPAddress(addresses[0].String()) + got, ok := dm.ResolveIPAddress(tt.dnsEvent.ContainerID, addresses[0].String()) if got != tt.want || ok != tt.wantOk { t.Errorf("ResolveIPAddress() got = %v, ok = %v, want = %v, wantOk = %v", got, ok, tt.want, tt.wantOk) } @@ -123,6 +162,144 @@ func TestResolveIPAddressFallback(t *testing.T) { } } +func TestContainerDNSIsolation(t *testing.T) { + dm := CreateDNSManager(1000) + + container1 := "workload-ai-client-123" + container2 := "workload-kube-proxy-456" + sharedCDNIP := "104.18.7.192" + + // Container 1 queries an AI provider sitting behind a shared CDN IP + dm.ReportEvent(&utils.StructEvent{ + EventType: utils.DnsEventType, + ContainerID: container1, + DNSName: "api.openai.com", + Addresses: []string{sharedCDNIP}, + }) + + // Container 2 queries an internal/unrelated service + dm.ReportEvent(&utils.StructEvent{ + EventType: utils.DnsEventType, + ContainerID: container2, + DNSName: "internal-service.local", + Addresses: []string{"10.0.0.50"}, + }) + + // Verify Container 1 resolves the IP to api.openai.com + domain1, ok1 := dm.ResolveIPAddress(container1, sharedCDNIP) + assert.True(t, ok1) + assert.Equal(t, "api.openai.com", domain1) + + // Verify Container 2 does NOT inherit api.openai.com when connecting to the same IP + domain2, ok2 := dm.ResolveIPAddress(container2, sharedCDNIP) + assert.False(t, ok2) + assert.Equal(t, "", domain2) + + // Verify Container 2 resolves its own queried domain + domain2Internal, ok2Internal := dm.ResolveIPAddress(container2, "10.0.0.50") + assert.True(t, ok2Internal) + assert.Equal(t, "internal-service.local", domain2Internal) + + // Verify Container 1 does NOT resolve Container 2's domain + domain1Internal, ok1Internal := dm.ResolveIPAddress(container1, "10.0.0.50") + assert.False(t, ok1Internal) + assert.Equal(t, "", domain1Internal) + + // Host queries a host service + dm.ReportEvent(&utils.StructEvent{ + EventType: utils.DnsEventType, + ContainerID: armotypes.HostContainerID, + DNSName: "host-service.internal", + Addresses: []string{"192.168.1.10"}, + }) + + // Verify "host" resolves host traffic + domainHost, okHost := dm.ResolveIPAddress(armotypes.HostContainerID, "192.168.1.10") + assert.True(t, okHost) + assert.Equal(t, "host-service.internal", domainHost) + + // Verify empty containerID does NOT resolve host traffic + domainEmpty, okEmpty := dm.ResolveIPAddress("", "192.168.1.10") + assert.False(t, okEmpty) + assert.Equal(t, "", domainEmpty) + + // Verify regular containers do NOT resolve host queries + domainContainer1Host, okContainer1Host := dm.ResolveIPAddress(container1, "192.168.1.10") + assert.False(t, okContainer1Host) + assert.Equal(t, "", domainContainer1Host) +} + +func TestContainerDNSLifecycleCleanup(t *testing.T) { + dm := CreateDNSManager(1000) + + containerID := "short-lived-pod-789" + ip := "93.184.216.34" + + // Add container + dm.ContainerCallback(containercollection.PubSubEvent{ + Type: containercollection.EventTypeAddContainer, + Container: &containercollection.Container{ + Runtime: containercollection.RuntimeMetadata{ + BasicRuntimeMetadata: eventtypes.BasicRuntimeMetadata{ + ContainerID: containerID, + }, + }, + }, + }) + + // Report DNS event + dm.ReportEvent(&utils.StructEvent{ + EventType: utils.DnsEventType, + ContainerID: containerID, + DNSName: "example.org", + Addresses: []string{ip}, + }) + + // Verify resolution works before removal + domain, ok := dm.ResolveIPAddress(containerID, ip) + assert.True(t, ok) + assert.Equal(t, "example.org", domain) + + // Remove container + dm.ContainerCallback(containercollection.PubSubEvent{ + Type: containercollection.EventTypeRemoveContainer, + Container: &containercollection.Container{ + Runtime: containercollection.RuntimeMetadata{ + BasicRuntimeMetadata: eventtypes.BasicRuntimeMetadata{ + ContainerID: containerID, + }, + }, + }, + }) + + // Verify existing resolutions remain readable during removal grace period for terminal profile save + domainDuringGrace, okDuringGrace := dm.ResolveIPAddress(containerID, ip) + assert.True(t, okDuringGrace) + assert.Equal(t, "example.org", domainDuringGrace) + + // In-flight DNS event arrives after container removal + dm.ReportEvent(&utils.StructEvent{ + EventType: utils.DnsEventType, + ContainerID: containerID, + DNSName: "late-arrival.org", + Addresses: []string{"1.2.3.4"}, + }) + + // Verify no cache was resurrected or updated for late-arriving event + domainLate, okLate := dm.ResolveIPAddress(containerID, "1.2.3.4") + assert.False(t, okLate) + assert.Equal(t, "", domainLate) + + // Explicitly simulate grace period expiration + dm.cacheMu.Lock() + dm.containerToAddressToDomain.Remove(containerID) + dm.cacheMu.Unlock() + + domainAfterGrace, okAfterGrace := dm.ResolveIPAddress(containerID, ip) + assert.False(t, okAfterGrace) + assert.Equal(t, "", domainAfterGrace) +} + func TestCacheFallbackBehavior(t *testing.T) { dm := CreateDNSManager(1000) @@ -319,7 +496,7 @@ func TestContainerCloudServices(t *testing.T) { t.Run("full container lifecycle with cloud services", func(t *testing.T) { // SETUP dm := CreateDNSManager(1000) - containerId := "test-container-123" + containerID := "test-container-123" testPid := uint32(1234) // Add container @@ -328,14 +505,14 @@ func TestContainerCloudServices(t *testing.T) { Container: &containercollection.Container{ Runtime: containercollection.RuntimeMetadata{ BasicRuntimeMetadata: eventtypes.BasicRuntimeMetadata{ - ContainerID: containerId, + ContainerID: containerID, }, }, }, }) // Verify container was added properly - pidToServices, found := dm.containerToCloudServices.Load(containerId) + pidToServices, found := dm.containerToCloudServices.Load(containerID) if !found { t.Fatal("Container was not added to containerToCloudServices map") } @@ -348,13 +525,13 @@ func TestContainerCloudServices(t *testing.T) { cloudEvents := []*utils.StructEvent{ { EventType: utils.DnsEventType, - ContainerID: containerId, + ContainerID: containerID, DNSName: "test.amazonaws.com.", Pid: testPid, }, { EventType: utils.DnsEventType, - ContainerID: containerId, + ContainerID: containerID, DNSName: "example.azure.com.", Pid: testPid, }, @@ -367,7 +544,7 @@ func TestContainerCloudServices(t *testing.T) { } // Verify services were added - resultServices := dm.ResolveContainerProcessToCloudServices(containerId, testPid) + resultServices := dm.ResolveContainerProcessToCloudServices(containerID, testPid) if resultServices == nil { t.Fatal("Expected non-nil service set") } @@ -383,14 +560,14 @@ func TestContainerCloudServices(t *testing.T) { Container: &containercollection.Container{ Runtime: containercollection.RuntimeMetadata{ BasicRuntimeMetadata: eventtypes.BasicRuntimeMetadata{ - ContainerID: containerId, + ContainerID: containerID, }, }, }, }) // Verify services are removed - resultServices = dm.ResolveContainerProcessToCloudServices(containerId, testPid) + resultServices = dm.ResolveContainerProcessToCloudServices(containerID, testPid) if resultServices != nil { t.Error("Expected nil services after container removal") } @@ -398,7 +575,7 @@ func TestContainerCloudServices(t *testing.T) { t.Run("max service cache size", func(t *testing.T) { dm := CreateDNSManager(1000) - containerId := "test-container-456" + containerID := "test-container-456" testPid := uint32(5678) // Add container @@ -407,14 +584,14 @@ func TestContainerCloudServices(t *testing.T) { Container: &containercollection.Container{ Runtime: containercollection.RuntimeMetadata{ BasicRuntimeMetadata: eventtypes.BasicRuntimeMetadata{ - ContainerID: containerId, + ContainerID: containerID, }, }, }, }) // Initialize the services set for the PID - if pidToServices, found := dm.containerToCloudServices.Load(containerId); found { + if pidToServices, found := dm.containerToCloudServices.Load(containerID); found { services := mapset.NewSet[string]() pidToServices.Set(testPid, services) } @@ -422,7 +599,7 @@ func TestContainerCloudServices(t *testing.T) { // Add more services than the cache size for i := 0; i <= maxServiceCacheSize+5; i++ { event := &utils.StructEvent{ - ContainerID: containerId, + ContainerID: containerID, DNSName: fmt.Sprintf("service%d.amazonaws.com.", i), Pid: testPid, } @@ -430,7 +607,7 @@ func TestContainerCloudServices(t *testing.T) { } // Verify cache size limit is enforced - services := dm.ResolveContainerProcessToCloudServices(containerId, testPid) + services := dm.ResolveContainerProcessToCloudServices(containerID, testPid) if services == nil { t.Fatal("Expected non-nil service set") } @@ -443,7 +620,7 @@ func TestContainerCloudServices(t *testing.T) { func TestCloudServiceCacheLimit(t *testing.T) { dm := CreateDNSManager(1000) - containerId := "test-container-456" + containerID := "test-container-456" testPid := uint32(5678) // Add container @@ -452,7 +629,7 @@ func TestCloudServiceCacheLimit(t *testing.T) { Container: &containercollection.Container{ Runtime: containercollection.RuntimeMetadata{ BasicRuntimeMetadata: eventtypes.BasicRuntimeMetadata{ - ContainerID: containerId, + ContainerID: containerID, }, }, }, @@ -462,7 +639,7 @@ func TestCloudServiceCacheLimit(t *testing.T) { for i := 0; i < maxServiceCacheSize+10; i++ { dm.ReportEvent(&utils.StructEvent{ EventType: utils.DnsEventType, - ContainerID: containerId, + ContainerID: containerID, DNSName: fmt.Sprintf("service%d.amazonaws.com.", i), Pid: testPid, }) @@ -471,11 +648,11 @@ func TestCloudServiceCacheLimit(t *testing.T) { // Give some time for events to be processed time.Sleep(100 * time.Millisecond) - services := dm.ResolveContainerProcessToCloudServices(containerId, testPid) + services := dm.ResolveContainerProcessToCloudServices(containerID, testPid) if services == nil { // Debug information t.Log("Debug: Checking container existence") - if pidToServices, found := dm.containerToCloudServices.Load(containerId); found { + if pidToServices, found := dm.containerToCloudServices.Load(containerID); found { t.Log("Container found in map") if services, found := pidToServices.Load(testPid); found { t.Log("PID found in map") @@ -494,3 +671,58 @@ func TestCloudServiceCacheLimit(t *testing.T) { services.Cardinality(), maxServiceCacheSize) } } + +func TestCreateDNSManager_NonPositiveSize(t *testing.T) { + for _, size := range []int{0, -1, -100} { + dm := CreateDNSManager(size) + assert.NotNil(t, dm, "CreateDNSManager(%d) must not return nil", size) + assert.Equal(t, defaultDNSCacheSize, dm.cacheSize) + assert.Equal(t, defaultPerContainerCacheSize, dm.perContainerCacheSize) + assert.NotNil(t, dm.hostAddressToDomain) + } +} + +func TestCreateDNSManager_BoundedSizing(t *testing.T) { + testCases := []struct { + name string + configuredSize int + expectedCacheSize int + expectedPerContainer int + }{ + { + name: "small size scales perContainerSize to bound memory", + configuredSize: 1000, + expectedCacheSize: 1000, + expectedPerContainer: 100, + }, + { + name: "very small size", + configuredSize: 50, + expectedCacheSize: 50, + expectedPerContainer: 5, + }, + { + name: "default size", + configuredSize: 10000, + expectedCacheSize: 10000, + expectedPerContainer: defaultPerContainerCacheSize, + }, + { + name: "large size", + configuredSize: 50000, + expectedCacheSize: 50000, + expectedPerContainer: defaultPerContainerCacheSize, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dm := CreateDNSManager(tc.configuredSize) + assert.NotNil(t, dm) + assert.Equal(t, tc.expectedCacheSize, dm.cacheSize) + assert.Equal(t, tc.expectedPerContainer, dm.perContainerCacheSize) + assert.NotNil(t, dm.hostAddressToDomain) + assert.NotNil(t, dm.containerToAddressToDomain) + }) + } +} diff --git a/pkg/networkstream/v1/network_stream.go b/pkg/networkstream/v1/network_stream.go index c789b633cf..8c3017d110 100644 --- a/pkg/networkstream/v1/network_stream.go +++ b/pkg/networkstream/v1/network_stream.go @@ -488,8 +488,9 @@ func (ns *NetworkStream) buildNetworkEvent(event utils.NetworkEvent, processTree var domain string var ok bool dstEndpoint := event.GetDstEndpoint() + containerID := event.GetContainerID() if event.GetPktType() == "OUTGOING" { - domain, ok = ns.dnsResolver.ResolveIPAddress(dstEndpoint.Addr) + domain, ok = ns.dnsResolver.ResolveIPAddress(containerID, dstEndpoint.Addr) if !ok { // Try to resolve the domain name domains, err := net.LookupAddr(dstEndpoint.Addr) @@ -502,7 +503,7 @@ func (ns *NetworkStream) buildNetworkEvent(event utils.NetworkEvent, processTree } } } else { - domain, _ = ns.dnsResolver.ResolveIPAddress(dstEndpoint.Addr) + domain, _ = ns.dnsResolver.ResolveIPAddress(containerID, dstEndpoint.Addr) } networkEvent := armotypes.NetworkStreamEvent{ diff --git a/pkg/networkstream/v1/network_stream_test.go b/pkg/networkstream/v1/network_stream_test.go index 496c6919fc..718523c7c1 100644 --- a/pkg/networkstream/v1/network_stream_test.go +++ b/pkg/networkstream/v1/network_stream_test.go @@ -28,7 +28,7 @@ type stubResolver struct{} var _ dnsmanager.DNSResolver = (*stubResolver)(nil) -func (stubResolver) ResolveIPAddress(string) (string, bool) { return "", true } +func (stubResolver) ResolveIPAddress(string, string) (string, bool) { return "", true } func (stubResolver) ResolveContainerProcessToCloudServices(string, uint32) mapset.Set[string] { return nil } diff --git a/pkg/objectcache/dnscache/dnscache.go b/pkg/objectcache/dnscache/dnscache.go index cfe945d743..836741947d 100644 --- a/pkg/objectcache/dnscache/dnscache.go +++ b/pkg/objectcache/dnscache/dnscache.go @@ -18,13 +18,13 @@ func NewDnsCache(dnsResolver dnsmanager.DNSResolver) *DnsCacheImpl { } } -func (d *DnsCacheImpl) ResolveIpToDomain(ip string) string { +func (d *DnsCacheImpl) ResolveIpToDomain(containerID string, ip string) string { if d.dnsResolver == nil { logger.L().Debug("DnsCacheImpl - resolver is not set") return "" } - domain, ok := d.dnsResolver.ResolveIPAddress(ip) + domain, ok := d.dnsResolver.ResolveIPAddress(containerID, ip) if !ok { return "" } diff --git a/pkg/objectcache/dnscache_interface.go b/pkg/objectcache/dnscache_interface.go index b874d0d33e..699fa8b5f1 100644 --- a/pkg/objectcache/dnscache_interface.go +++ b/pkg/objectcache/dnscache_interface.go @@ -1,14 +1,14 @@ package objectcache type DnsCache interface { - ResolveIpToDomain(ip string) string + ResolveIpToDomain(containerID string, ip string) string } -var _DnsCache = (*DnsCacheMock)(nil) +var _ DnsCache = (*DnsCacheMock)(nil) type DnsCacheMock struct { } -func (dc *DnsCacheMock) ResolveIpToDomain(_ string) string { +func (dc *DnsCacheMock) ResolveIpToDomain(_ string, _ string) string { return "" } diff --git a/pkg/objectcache/v1/mock.go b/pkg/objectcache/v1/mock.go index bfc98a7e0d..2184b081c0 100644 --- a/pkg/objectcache/v1/mock.go +++ b/pkg/objectcache/v1/mock.go @@ -285,9 +285,16 @@ func (r *RuleObjectCacheMock) SetDnsCache(dnsCache map[string]string) { r.dnsCache = dnsCache } -func (r *RuleObjectCacheMock) ResolveIpToDomain(ip string) string { - if domain, ok := r.dnsCache[ip]; ok { - return domain +func (r *RuleObjectCacheMock) ResolveIpToDomain(containerID string, ip string) string { + if r.dnsCache != nil { + if containerID != "" { + if domain, ok := r.dnsCache[containerID+":"+ip]; ok { + return domain + } + } + if domain, ok := r.dnsCache[ip]; ok { + return domain + } } return "" } diff --git a/pkg/objectcache/v1/objectcache_test.go b/pkg/objectcache/v1/objectcache_test.go index 6af7e69c5e..d405aad1f5 100644 --- a/pkg/objectcache/v1/objectcache_test.go +++ b/pkg/objectcache/v1/objectcache_test.go @@ -19,3 +19,20 @@ func TestContainerProfileCache(t *testing.T) { k8sObjectCache := NewObjectCache(nil, cp, nil) assert.NotNil(t, k8sObjectCache.ContainerProfileCache()) } + +func TestRuleObjectCacheMock_ResolveIpToDomain(t *testing.T) { + mock := &RuleObjectCacheMock{} + mock.SetDnsCache(map[string]string{ + "cont-1:1.2.3.4": "scoped-domain.com", + "5.6.7.8": "global-domain.com", + }) + + // Test container-scoped resolution + assert.Equal(t, "scoped-domain.com", mock.ResolveIpToDomain("cont-1", "1.2.3.4")) + // Test container with fallback to unscoped key + assert.Equal(t, "global-domain.com", mock.ResolveIpToDomain("cont-2", "5.6.7.8")) + // Test unscoped lookup + assert.Equal(t, "global-domain.com", mock.ResolveIpToDomain("", "5.6.7.8")) + // Test missing resolution + assert.Equal(t, "", mock.ResolveIpToDomain("cont-1", "9.9.9.9")) +} diff --git a/tests/component_test.go b/tests/component_test.go index dce0e20cb5..d4d9e736f1 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -3781,6 +3781,8 @@ func Test_30_IgnoreExcludeAndLearningDuration(t *testing.T) { restore := withNodeAgentConfig(t, func(cfg map[string]any) { cfg["excludeNamespaces"] = []string{excluded.Name} cfg["excludeLabels"] = map[string][]string{"skip-me": {"true"}} + cfg["maxSniffingTimePerContainer"] = "45s" + cfg["initialDelay"] = "10s" }) defer restore() @@ -3791,7 +3793,7 @@ func Test_30_IgnoreExcludeAndLearningDuration(t *testing.T) { require.NoError(t, err, "control workload") require.NoError(t, ctl.WaitForReady(80)) - time.Sleep(90 * time.Second) + require.NoError(t, ctl.WaitForContainerProfileCompletion(30), "control workload profile completion") exCPs, _ := exNS.GetContainerProfiles() require.Empty(t, exCPs, "an excluded-namespace workload must produce NO ContainerProfile") @@ -3819,8 +3821,8 @@ func Test_30_IgnoreExcludeAndLearningDuration(t *testing.T) { require.NoError(t, err, "workload") require.NoError(t, wl.WaitForReady(80)) - deadline := time.Now().Add(90 * time.Second) - require.NoError(t, wl.WaitForContainerProfileCompletion(90), "profile must complete within the shortened window") + deadline := time.Now().Add(180 * time.Second) + require.NoError(t, wl.WaitForContainerProfileCompletion(30), "profile must complete within the shortened window") require.True(t, time.Now().Before(deadline), "completion must track the configured maxSniffingTimePerContainer, not a longer default") }) diff --git a/tests/testutils/k8s.go b/tests/testutils/k8s.go index b2792d04b9..d98566fa7c 100644 --- a/tests/testutils/k8s.go +++ b/tests/testutils/k8s.go @@ -690,11 +690,13 @@ func RestartDaemonSet(namespace, name string) error { daemonset.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) // Update the daemonset - _, err = k8sClient.KubernetesClient.AppsV1().DaemonSets(namespace).Update(ctx, daemonset, metav1.UpdateOptions{}) + updated, err := k8sClient.KubernetesClient.AppsV1().DaemonSets(namespace).Update(ctx, daemonset, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("failed to update daemonset %s/%s: %w", namespace, name, err) } + targetGen := updated.Generation + // Wait for the daemonset to be ready err = backoff.RetryNotify(func() error { updatedDS, err := k8sClient.KubernetesClient.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{}) @@ -702,6 +704,11 @@ func RestartDaemonSet(namespace, name string) error { return err } + if updatedDS.Status.ObservedGeneration < targetGen { + return fmt.Errorf("daemonset %s/%s rollout not observed yet (observed gen %d < target %d)", + namespace, name, updatedDS.Status.ObservedGeneration, targetGen) + } + if updatedDS.Status.NumberReady != updatedDS.Status.DesiredNumberScheduled { return fmt.Errorf("daemonset %s/%s not ready: %d/%d pods ready", namespace, name, updatedDS.Status.NumberReady, updatedDS.Status.DesiredNumberScheduled)