From 716052077b2b58b87d980b60ed1cab9e72180882 Mon Sep 17 00:00:00 2001 From: Louis Ruch Date: Wed, 13 May 2026 16:20:31 -0700 Subject: [PATCH] s --- plugin/service/host/README.md | 41 +- plugin/service/host/attributes.go | 46 ++ plugin/service/host/attributes_test.go | 100 ++++ plugin/service/host/const.go | 12 + plugin/service/host/plugin.go | 53 +- plugin/service/host/plugin_test.go | 703 ++++++++++++++++++++++++- 6 files changed, 931 insertions(+), 24 deletions(-) diff --git a/plugin/service/host/README.md b/plugin/service/host/README.md index 6c18f3d..eee4e01 100644 --- a/plugin/service/host/README.md +++ b/plugin/service/host/README.md @@ -16,6 +16,36 @@ boundary host-catalogs create plugin \ -secret secret_access_key='SECRET' ``` +To create a host catalog that returns addresses only from the primary network +interface: + +``` +boundary host-catalogs create plugin \ + -scope-id p_1234567890 \ + -name "Example Plugin-Based Host Catalog" \ + -description "Description for plugin-based host catalog" \ + -plugin-name aws \ + -attr region=REGION \ + -attr primary_interface_only=true \ + -secret access_key_id='KEY' \ + -secret secret_access_key='SECRET' +``` + +To create a host catalog that excludes public IPv4 addresses while keeping +private IPv4 addresses: + +``` +boundary host-catalogs create plugin \ + -scope-id p_1234567890 \ + -name "Example Plugin-Based Host Catalog" \ + -description "Description for plugin-based host catalog" \ + -plugin-name aws \ + -attr region=REGION \ + -attr exclude_public_ips=true \ + -secret access_key_id='KEY' \ + -secret secret_access_key='SECRET' +``` + To create a host set, filtering the host set based on tag keys `foo` or `bar` (either tag can be present), ensuring that any targets set to this host set only connect to external addresses in the `54.0.0.0/8` class A subnet: @@ -75,7 +105,6 @@ a configured IAM user for this provider: "Resource": "*" } ] -} ``` * `iam:GetUser`, `iam:CreateAccessKey`, and `iam:DeleteAccessKey`, configured to @@ -116,6 +145,16 @@ The following `attributes` are valid on an AWS host catalog resource: provider. - `role_tags` (object): The key-value pair tags configured for the `AssumeRole` provider. +- `primary_interface_only` (bool): If `true`, only addresses from the primary + ENI are returned. When `false` or omitted, addresses from all attached ENIs + are considered. +- `exclude_private_ips` (bool): If `true`, private IPv4 addresses are omitted. +- `exclude_public_ips` (bool): If `true`, public IPv4 addresses are omitted. + +If none of the exclusion flags are set, the plugin preserves the historical +behavior and returns all discovered address classes. The exclusion flags apply +to both IPv4 and IPv6 addresses based on whether the IP is classified as public +or private. The following `secrets` are required on an AWS host catalog resource: diff --git a/plugin/service/host/attributes.go b/plugin/service/host/attributes.go index 5890adb..6984c03 100644 --- a/plugin/service/host/attributes.go +++ b/plugin/service/host/attributes.go @@ -22,6 +22,18 @@ type CatalogAttributes struct { // DualStack is used for configuring how the aws client will resolve requests. DualStack bool + + // PrimaryInterfaceOnly restricts discovery to the instance's primary ENI. + PrimaryInterfaceOnly bool + + // ExcludePrivateIps controls whether private IPv4 addresses are omitted. + ExcludePrivateIps bool + + // ExcludePublicIps controls whether public IPv4 addresses are omitted. + ExcludePublicIps bool + + // ExcludeIpv6 controls whether IPv6 addresses are omitted. + ExcludeIpv6 bool } func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { @@ -40,6 +52,36 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { } delete(unknownFields, ConstAwsDualStack) + primaryInterfaceOnly, err := values.GetBoolValue(in, ConstPrimaryInterfaceOnly, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstPrimaryInterfaceOnly)] = err.Error() + } + delete(unknownFields, ConstPrimaryInterfaceOnly) + + excludePrivateIPs, err := values.GetBoolValue(in, ConstExcludePrivateIps, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstExcludePrivateIps)] = err.Error() + } + delete(unknownFields, ConstExcludePrivateIps) + + excludePublicIPs, err := values.GetBoolValue(in, ConstExcludePublicIps, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstExcludePublicIps)] = err.Error() + } + delete(unknownFields, ConstExcludePublicIps) + + excludeIpv6, err := values.GetBoolValue(in, ConstExcludeIpv6, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstExcludeIpv6)] = err.Error() + } + delete(unknownFields, ConstExcludeIpv6) + + if excludePrivateIPs && excludePublicIPs && excludeIpv6 { + badFields[fmt.Sprintf("attributes.%s", ConstExcludePrivateIps)] = "cannot be combined with exclude_public_ips and exclude_ipv6" + badFields[fmt.Sprintf("attributes.%s", ConstExcludePublicIps)] = "cannot be combined with exclude_private_ips and exclude_ipv6" + badFields[fmt.Sprintf("attributes.%s", ConstExcludeIpv6)] = "cannot be combined with exclude_private_ips and exclude_public_ips" + } + for s := range unknownFields { switch s { // Ignore knownFields from CredentialAttributes @@ -67,6 +109,10 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { return &CatalogAttributes{ CredentialAttributes: credAttributes, DualStack: dualStack, + PrimaryInterfaceOnly: primaryInterfaceOnly, + ExcludePrivateIps: excludePrivateIPs, + ExcludePublicIps: excludePublicIPs, + ExcludeIpv6: excludeIpv6, }, nil } diff --git a/plugin/service/host/attributes_test.go b/plugin/service/host/attributes_test.go index 5e90f17..e6c3584 100644 --- a/plugin/service/host/attributes_test.go +++ b/plugin/service/host/attributes_test.go @@ -59,6 +59,106 @@ func TestGetCatalogAttributes(t *testing.T) { DualStack: true, }, }, + { + name: "with primary interface only", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "primary_interface_only": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + PrimaryInterfaceOnly: true, + }, + }, + { + name: "with exclude public ip", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "exclude_public_ips": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + ExcludePublicIps: true, + }, + }, + { + name: "with exclude private ip", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "exclude_private_ips": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + ExcludePrivateIps: true, + }, + }, + { + name: "with mixed attributes", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "dual_stack": structpb.NewBoolValue(true), + "exclude_private_ips": structpb.NewBoolValue(true), + "exclude_ipv6": structpb.NewBoolValue(true), + "primary_interface_only": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + DualStack: true, + ExcludePrivateIps: true, + ExcludeIpv6: true, + PrimaryInterfaceOnly: true, + }, + }, + { + name: "can exclude public and private IPs when IPv6 remains enabled", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "exclude_private_ips": structpb.NewBoolValue(true), + "exclude_public_ips": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + ExcludePrivateIps: true, + ExcludePublicIps: true, + }, + }, + { + name: "cannot exclude public private and IPv6 together", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "exclude_private_ips": structpb.NewBoolValue(true), + "exclude_public_ips": structpb.NewBoolValue(true), + "exclude_ipv6": structpb.NewBoolValue(true), + }, + }, + expectedErrContains: "attributes.exclude_ipv6: cannot be combined with exclude_private_ips and exclude_public_ips, attributes.exclude_private_ips: cannot be combined with exclude_public_ips and exclude_ipv6, attributes.exclude_public_ips: cannot be combined with exclude_private_ips and exclude_ipv6", + }, { name: "default", in: &structpb.Struct{ diff --git a/plugin/service/host/const.go b/plugin/service/host/const.go index 1a6a8d2..10295d9 100644 --- a/plugin/service/host/const.go +++ b/plugin/service/host/const.go @@ -13,4 +13,16 @@ const ( // ConstAwsDualStack is the key for the dualstack flag in the aws s3 client. ConstAwsDualStack = "dual_stack" + + // ConstPrimaryInterfaceOnly limits host discovery to the primary ENI. + ConstPrimaryInterfaceOnly = "primary_interface_only" + + // ConstExcludePrivateIps excludes private IPv4 addresses. + ConstExcludePrivateIps = "exclude_private_ips" + + // ConstExcludePublicIps excludes public IPv4 addresses associated to ENI IPs. + ConstExcludePublicIps = "exclude_public_ips" + + // ConstExcludeIpv6 excludes IPv6 addresses. + ConstExcludeIpv6 = "exclude_ipv6" ) diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go index 5721e37..34f0146 100644 --- a/plugin/service/host/plugin.go +++ b/plugin/service/host/plugin.go @@ -1,8 +1,8 @@ -package host - // Copyright IBM Corp. 2021, 2026 // SPDX-License-Identifier: MPL-2.0 +package host + import ( "context" "fmt" @@ -550,7 +550,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (* // set of hosts afterwards (possibly removing duplicates). for _, reservation := range output.Reservations { for _, instance := range reservation.Instances { - host, err := awsInstanceToHost(instance) + host, err := awsInstanceToHost(instance, catalogAttributes) if err != nil { return nil, errors.BadRequestStatus("error processing host results for host set id %q: %s", query.Id, err) } @@ -648,10 +648,13 @@ func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.Descri // awsInstanceToHost processes data from an ec2.Instance and returns // a ListHostsResponseHost with the ID and network addresses // populated. -func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, error) { +func awsInstanceToHost(instance types.Instance, catalogAttributes *CatalogAttributes) (*pb.ListHostsResponseHost, error) { if aws.ToString(instance.InstanceId) == "" { return nil, fmt.Errorf("response integrity error: missing instance id") } + if catalogAttributes == nil { + return nil, fmt.Errorf("catalog attributes are required") + } result := new(pb.ListHostsResponseHost) @@ -666,22 +669,46 @@ func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, erro } } - result.IpAddresses = appendDistinct(result.IpAddresses, instance.PrivateIpAddress, instance.PublicIpAddress) - result.DnsNames = appendDistinct(result.DnsNames, instance.PrivateDnsName, instance.PublicDnsName) + if !catalogAttributes.ExcludePrivateIps { + result.IpAddresses = appendDistinct(result.IpAddresses, instance.PrivateIpAddress) + result.DnsNames = appendDistinct(result.DnsNames, instance.PrivateDnsName) + } + if !catalogAttributes.ExcludePublicIps { + result.IpAddresses = appendDistinct(result.IpAddresses, instance.PublicIpAddress) + result.DnsNames = appendDistinct(result.DnsNames, instance.PublicDnsName) + } // Now go through all of the interfaces and log the IP address of // every interface. for _, iface := range instance.NetworkInterfaces { + if catalogAttributes.PrimaryInterfaceOnly { + if iface.Attachment == nil || aws.ToInt32(iface.Attachment.DeviceIndex) != 0 { + // We only want to sync the primary interface so skip any others + continue + } + } + // Populate default IP addresses/DNS name similar to how we do // for the entire instance. - result.IpAddresses = appendDistinct(result.IpAddresses, iface.PrivateIpAddress) - result.DnsNames = appendDistinct(result.DnsNames, iface.PrivateDnsName) + if !catalogAttributes.ExcludePrivateIps { + result.IpAddresses = appendDistinct(result.IpAddresses, iface.PrivateIpAddress) + result.DnsNames = appendDistinct(result.DnsNames, iface.PrivateDnsName) + } // Iterate through the private IP addresses and log the // information. for _, addr := range iface.PrivateIpAddresses { - result.IpAddresses = appendDistinct(result.IpAddresses, addr.PrivateIpAddress) - result.DnsNames = appendDistinct(result.DnsNames, addr.PrivateDnsName) + if !catalogAttributes.ExcludePrivateIps { + result.IpAddresses = appendDistinct(result.IpAddresses, addr.PrivateIpAddress) + result.DnsNames = appendDistinct(result.DnsNames, addr.PrivateDnsName) + } + + if catalogAttributes.ExcludePublicIps { + // skip adding public addresses if we are excluding them + continue + } + + // PublicIP for iface is only available via an association to the private IP if addr.Association != nil { result.IpAddresses = appendDistinct(result.IpAddresses, addr.Association.PublicIp) result.DnsNames = appendDistinct(result.DnsNames, addr.Association.PublicDnsName) @@ -689,8 +716,10 @@ func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, erro } // Add the IPv6 addresses. - for _, addr := range iface.Ipv6Addresses { - result.IpAddresses = appendDistinct(result.IpAddresses, addr.Ipv6Address) + if !catalogAttributes.ExcludeIpv6 { + for _, addr := range iface.Ipv6Addresses { + result.IpAddresses = appendDistinct(result.IpAddresses, addr.Ipv6Address) + } } } diff --git a/plugin/service/host/plugin_test.go b/plugin/service/host/plugin_test.go index 0844e3e..30b45d9 100644 --- a/plugin/service/host/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -638,7 +638,8 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + }, + }, Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -664,7 +665,8 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + }, + }, Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -694,7 +696,8 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + }, + }, Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -721,7 +724,8 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + }, + }, Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -2383,10 +2387,11 @@ func TestBuildDescribeInstancesInput(t *testing.T) { func TestAwsInstanceToHost(t *testing.T) { cases := []struct { - name string - instance types.Instance - expected *pb.ListHostsResponseHost - expectedErr string + name string + instance types.Instance + catalogAttributes *CatalogAttributes + expected *pb.ListHostsResponseHost + expectedErr string }{ { name: "missing instance id", @@ -2591,14 +2596,45 @@ func TestAwsInstanceToHost(t *testing.T) { }, Ipv6Addresses: []types.InstanceIpv6Address{ {Ipv6Address: nil}, // Just coverage for nil assertion which is skipped - {Ipv6Address: aws.String("some::fake::address")}, + {Ipv6Address: aws.String("2001:db8::1")}, }, }, }, }, expected: &pb.ListHostsResponseHost{ ExternalId: "foobar", - IpAddresses: []string{"10.0.0.1", "1.1.1.1", "some::fake::address"}, + IpAddresses: []string{"10.0.0.1", "1.1.1.1", "2001:db8::1"}, + DnsNames: []string{"test.example.internal", "test.example.com"}, + }, + }, + { + name: "good, single IP w/public addr", + instance: types.Instance{ + InstanceId: aws.String("foobar"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PublicIpAddress: aws.String("1.1.1.1"), + PublicDnsName: aws.String("test.example.com"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.1.1.1"), + PublicDnsName: aws.String("test.example.com"), + }, + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + }, + }, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "foobar", + IpAddresses: []string{"10.0.0.1", "1.1.1.1"}, DnsNames: []string{"test.example.internal", "test.example.com"}, }, }, @@ -2676,18 +2712,663 @@ func TestAwsInstanceToHost(t *testing.T) { DnsNames: []string{"test.example.internal", "test.example.com"}, }, }, + { + name: "primary_interface_only keeps all addresses on primary ENI", + catalogAttributes: &CatalogAttributes{ + PrimaryInterfaceOnly: true, + }, + instance: types.Instance{ + InstanceId: aws.String("foobar"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PublicIpAddress: aws.String("1.1.1.1"), + PublicDnsName: aws.String("test.example.com"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + Attachment: &types.InstanceNetworkInterfaceAttachment{ + DeviceIndex: aws.Int32(0), + }, + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + Primary: aws.Bool(true), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + }, + // Secondary IPs on primary ENI (e.g. VPC CNI pod IPs). + { + Primary: aws.Bool(false), + PrivateIpAddress: aws.String("10.0.0.42"), + PrivateDnsName: aws.String("pod-a.example.internal"), + }, + { + Primary: aws.Bool(false), + PrivateIpAddress: aws.String("10.0.0.43"), + PrivateDnsName: aws.String("pod-b.example.internal"), + }, + }, + }, + // Secondary ENI attached by VPC CNI for additional pod IPs. + { + Attachment: &types.InstanceNetworkInterfaceAttachment{ + DeviceIndex: aws.Int32(1), + }, + PrivateIpAddress: aws.String("10.0.0.99"), + PrivateDnsName: aws.String("eni1.example.internal"), + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "foobar", + IpAddresses: []string{"10.0.0.1", "1.1.1.1", "10.0.0.42", "10.0.0.43"}, + DnsNames: []string{"test.example.internal", "test.example.com", "pod-a.example.internal", "pod-b.example.internal"}, + }, + }, + { + name: "primary_interface_only keeps IPv6 on primary ENI", + catalogAttributes: &CatalogAttributes{ + PrimaryInterfaceOnly: true, + }, + instance: types.Instance{ + InstanceId: aws.String("foobar"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + // Secondary ENI listed first to verify ordering does not matter. + { + Attachment: &types.InstanceNetworkInterfaceAttachment{ + DeviceIndex: aws.Int32(1), + }, + PrivateIpAddress: aws.String("10.0.0.99"), + Ipv6Addresses: []types.InstanceIpv6Address{ + {Ipv6Address: aws.String("2001:db8::2")}, + }, + }, + { + Attachment: &types.InstanceNetworkInterfaceAttachment{ + DeviceIndex: aws.Int32(0), + }, + PrivateIpAddress: aws.String("10.0.0.1"), + Ipv6Addresses: []types.InstanceIpv6Address{ + {Ipv6Address: aws.String("2001:db8::3")}, + {Ipv6Address: aws.String("2001:db8::4")}, + }, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "foobar", + IpAddresses: []string{"10.0.0.1", "2001:db8::3", "2001:db8::4"}, + DnsNames: []string{"test.example.internal"}, + }, + }, + { + name: "primary_interface_only without interfaces still returns top-level IPs", + catalogAttributes: &CatalogAttributes{ + PrimaryInterfaceOnly: true, + }, + instance: types.Instance{ + InstanceId: aws.String("foobar"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PublicIpAddress: aws.String("1.1.1.1"), + PublicDnsName: aws.String("test.example.com"), + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "foobar", + IpAddresses: []string{"10.0.0.1", "1.1.1.1"}, + DnsNames: []string{"test.example.internal", "test.example.com"}, + }, + }, + { + name: "exclude_public_ips excludes public IPv4 sources but keeps IPv6", + catalogAttributes: &CatalogAttributes{ + ExcludePublicIps: true, + }, + instance: types.Instance{ + InstanceId: aws.String("i-1234"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PublicIpAddress: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + }, + }, + }, + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("2001:db8::1")}}, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "i-1234", + IpAddresses: []string{"10.0.0.1", "2001:db8::1"}, + DnsNames: []string{"host.internal"}, + }, + }, + { + name: "exclude_private_ips excludes private IPv4 sources but keeps IPv6", + catalogAttributes: &CatalogAttributes{ + ExcludePrivateIps: true, + }, + instance: types.Instance{ + InstanceId: aws.String("i-1234"), + PublicIpAddress: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + {Association: &types.InstanceNetworkInterfaceAssociation{PublicIp: aws.String("1.2.3.4"), PublicDnsName: aws.String("host.public")}}, + }, + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("2001:db8::1")}}, + }, + { + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + {Association: &types.InstanceNetworkInterfaceAssociation{PublicIp: aws.String("1.2.3.5"), PublicDnsName: aws.String("svc.public")}}, + }, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "i-1234", + IpAddresses: []string{"1.2.3.4", "2001:db8::1", "1.2.3.5"}, + DnsNames: []string{"host.public", "svc.public"}, + }, + }, + { + name: "exclude_public_ips keeps IPv6 addresses", + catalogAttributes: &CatalogAttributes{ + ExcludePublicIps: true, + }, + instance: types.Instance{ + InstanceId: aws.String("i-1234"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("fd00::1")}}, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "i-1234", + IpAddresses: []string{"10.0.0.1", "fd00::1"}, + DnsNames: []string{"host.internal"}, + }, + }, + { + name: "exclude_ipv6 omits IPv6 while preserving IPv4 and DNS", + catalogAttributes: &CatalogAttributes{ + ExcludeIpv6: true, + }, + instance: types.Instance{ + InstanceId: aws.String("i-1234"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PublicIpAddress: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + }, + }, + }, + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("2001:db8::1")}}, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "i-1234", + IpAddresses: []string{"10.0.0.1", "1.2.3.4"}, + DnsNames: []string{"host.internal", "host.public"}, + }, + }, + { + name: "exclude_public_ips with exclude_ipv6 keeps only private IPv4 sources", + catalogAttributes: &CatalogAttributes{ + ExcludePublicIps: true, + ExcludeIpv6: true, + }, + instance: types.Instance{ + InstanceId: aws.String("i-1234"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PublicIpAddress: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + }, + }, + { + PrivateIpAddress: aws.String("10.0.0.2"), + PrivateDnsName: aws.String("svc.internal"), + }, + }, + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("2001:db8::1")}}, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "i-1234", + IpAddresses: []string{"10.0.0.1", "10.0.0.2"}, + DnsNames: []string{"host.internal", "svc.internal"}, + }, + }, + { + name: "exclude_private_ips with exclude_ipv6 keeps only public IPv4 sources", + catalogAttributes: &CatalogAttributes{ + ExcludePrivateIps: true, + ExcludeIpv6: true, + }, + instance: types.Instance{ + InstanceId: aws.String("i-1234"), + PublicIpAddress: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + }, + }, + { + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.2.3.5"), + PublicDnsName: aws.String("svc.public"), + }, + }, + }, + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("fd00::1")}}, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "i-1234", + IpAddresses: []string{"1.2.3.4", "1.2.3.5"}, + DnsNames: []string{"host.public", "svc.public"}, + }, + }, + { + name: "primary_interface_only with exclude_ipv6 drops IPv6 from primary ENI", + catalogAttributes: &CatalogAttributes{ + PrimaryInterfaceOnly: true, + ExcludeIpv6: true, + }, + instance: types.Instance{ + InstanceId: aws.String("foobar"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PublicIpAddress: aws.String("1.1.1.1"), + PublicDnsName: aws.String("test.example.com"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + Attachment: &types.InstanceNetworkInterfaceAttachment{ + DeviceIndex: aws.Int32(0), + }, + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + PrivateIpAddress: aws.String("10.0.0.42"), + PrivateDnsName: aws.String("pod-a.example.internal"), + }, + }, + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("2001:db8::3")}}, + }, + { + Attachment: &types.InstanceNetworkInterfaceAttachment{ + DeviceIndex: aws.Int32(1), + }, + PrivateIpAddress: aws.String("10.0.0.99"), + PrivateDnsName: aws.String("eni1.example.internal"), + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("2001:db8::4")}}, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "foobar", + IpAddresses: []string{"10.0.0.1", "1.1.1.1", "10.0.0.42"}, + DnsNames: []string{"test.example.internal", "test.example.com", "pod-a.example.internal"}, + }, + }, + { + name: "exclude_ipv6 on IPv6-only interface leaves no addresses", + catalogAttributes: &CatalogAttributes{ + ExcludeIpv6: true, + }, + instance: types.Instance{ + InstanceId: aws.String("i-1234"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + Ipv6Addresses: []types.InstanceIpv6Address{ + {Ipv6Address: aws.String("2001:db8::1")}, + {Ipv6Address: aws.String("fd00::1")}, + }, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "i-1234", + }, + }, + { + name: "deduplicates repeated addresses and DNS names across instance and interfaces", + instance: types.Instance{ + InstanceId: aws.String("i-1234"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PublicIpAddress: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + }, + }, + }, + Ipv6Addresses: []types.InstanceIpv6Address{ + {Ipv6Address: aws.String("2001:db8::1")}, + {Ipv6Address: aws.String("2001:db8::1")}, + }, + }, + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + }, + }, + }, + Ipv6Addresses: []types.InstanceIpv6Address{ + {Ipv6Address: aws.String("2001:db8::1")}, + }, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "i-1234", + IpAddresses: []string{"10.0.0.1", "1.2.3.4", "2001:db8::1"}, + DnsNames: []string{"host.internal", "host.public"}, + }, + }, + { + name: "primary_interface_only skips interfaces without attachment metadata", + catalogAttributes: &CatalogAttributes{ + PrimaryInterfaceOnly: true, + }, + instance: types.Instance{ + InstanceId: aws.String("i-1234"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PublicIpAddress: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddress: aws.String("10.0.0.99"), + PrivateDnsName: aws.String("missing-attachment.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + PrivateIpAddress: aws.String("10.0.0.99"), + PrivateDnsName: aws.String("missing-attachment.internal"), + }, + }, + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("2001:db8::99")}}, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "i-1234", + IpAddresses: []string{"10.0.0.1", "1.2.3.4"}, + DnsNames: []string{"host.internal", "host.public"}, + }, + }, + { + name: "exclude_private_ips removes private DNS names even when public addresses remain", + catalogAttributes: &CatalogAttributes{ + ExcludePrivateIps: true, + }, + instance: types.Instance{ + InstanceId: aws.String("i-1234"), + PublicIpAddress: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("host.internal"), + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.2.3.4"), + PublicDnsName: aws.String("host.public"), + }, + }, + { + PrivateIpAddress: aws.String("10.0.0.2"), + PrivateDnsName: aws.String("svc.internal"), + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.2.3.5"), + PublicDnsName: aws.String("svc.public"), + }, + }, + }, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "i-1234", + IpAddresses: []string{"1.2.3.4", "1.2.3.5"}, + DnsNames: []string{"host.public", "svc.public"}, + }, + }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - actual, err := awsInstanceToHost(tc.instance) + attrs := tc.catalogAttributes + if attrs == nil { + attrs = &CatalogAttributes{} + } + actual, err := awsInstanceToHost(tc.instance, attrs) if tc.expectedErr != "" { require.EqualError(err, tc.expectedErr) return } + require.NoError(err) + require.Equal(tc.expected, actual) + }) + } +} +func TestGetCatalogAttributesAddressSelection(t *testing.T) { + cases := []struct { + name string + in *structpb.Struct + expectedErr string + expected *CatalogAttributes + }{ + { + name: "valid selectors", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "primary_interface_only": structpb.NewBoolValue(true), + "exclude_public_ips": structpb.NewBoolValue(true), + "exclude_ipv6": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + PrimaryInterfaceOnly: true, + ExcludePublicIps: true, + ExcludeIpv6: true, + }, + }, + { + name: "exclude_private_ips can be combined with exclude_ipv6", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "exclude_private_ips": structpb.NewBoolValue(true), + "exclude_ipv6": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + ExcludePrivateIps: true, + ExcludeIpv6: true, + }, + }, + { + name: "exclude_public_ips can be combined with exclude_ipv6", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "exclude_public_ips": structpb.NewBoolValue(true), + "exclude_ipv6": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + ExcludePublicIps: true, + ExcludeIpv6: true, + }, + }, + { + name: "exclude_private_ips and exclude_public_ips can be combined when IPv6 remains enabled", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "exclude_private_ips": structpb.NewBoolValue(true), + "exclude_public_ips": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + ExcludePrivateIps: true, + ExcludePublicIps: true, + }, + }, + { + name: "exclude_private_ips and exclude_public_ips cannot also exclude_ipv6", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "exclude_private_ips": structpb.NewBoolValue(true), + "exclude_public_ips": structpb.NewBoolValue(true), + "exclude_ipv6": structpb.NewBoolValue(true), + }, + }, + expectedErr: "attributes.exclude_ipv6: cannot be combined with exclude_private_ips and exclude_public_ips, attributes.exclude_private_ips: cannot be combined with exclude_public_ips and exclude_ipv6, attributes.exclude_public_ips: cannot be combined with exclude_private_ips and exclude_ipv6", + }, + { + name: "exclude_ipv6 alone is accepted", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "exclude_ipv6": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + ExcludeIpv6: true, + }, + }, + { + name: "exclude_ipv6 requires a boolean", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "exclude_ipv6": structpb.NewStringValue("true"), + }, + }, + expectedErr: "attributes.exclude_ipv6", + }, + { + name: "no selectors configured preserves historical behavior", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + actual, err := getCatalogAttributes(tc.in) + if tc.expectedErr != "" { + require.Error(err) + require.Contains(err.Error(), tc.expectedErr) + return + } require.NoError(err) require.Equal(tc.expected, actual) })