mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-21 03:36:35 +00:00
feat: accept + enforce customer security groups on NLBs (#454)
NLBs hard-rejected customer security groups and always minted a managed SG that auto-opened listener ports, so aws-load-balancer-controller (which assigns SGs to type=external NLBs) got a 400 and looped. Accept up to 5 customer SGs on an NLB: when supplied they replace the managed SG on the ENIs and the caller owns the listener-port rules (CreateListener already no-ops its auto-open when there is no managed SG); without them the managed-SG default is unchanged. SetSecurityGroups follows AWS create-time semantics — replace SGs on an NLB created with SGs, reject adding them to one created without.
This commit is contained in:
committed by
Julian Sommer
parent
7c951dfbe9
commit
8941b4333c
@@ -1117,8 +1117,10 @@ func (s *ELBv2ServiceImpl) createLoadBalancer(input *elbv2.CreateLoadBalancerInp
|
||||
}
|
||||
}
|
||||
|
||||
// NLBs do not support security groups.
|
||||
if lbType == LoadBalancerTypeNetwork && len(input.SecurityGroups) > 0 {
|
||||
// AWS caps a load balancer at 5 security groups (ALBs always; NLBs since Aug
|
||||
// 2023). NLBs with customer SGs skip the managed SG and let the caller own the
|
||||
// listener-port rules; NLBs without SGs keep the managed-SG default below.
|
||||
if len(input.SecurityGroups) > maxLBSecurityGroups {
|
||||
return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
|
||||
}
|
||||
|
||||
@@ -1170,11 +1172,12 @@ func (s *ELBv2ServiceImpl) createLoadBalancer(input *elbv2.CreateLoadBalancerInp
|
||||
vpcID := ""
|
||||
var nlbManagedSGID string
|
||||
if s.VPCService != nil && len(subnets) > 0 {
|
||||
// NLBs reject customer SGs, so mint a dedicated managed SG for all LB ENIs;
|
||||
// CreateListener opens listener ports on it. Without this, ENIs fall back
|
||||
// to the VPC default SG and inbound listener traffic is dropped.
|
||||
// An NLB without customer SGs gets a dedicated managed SG on all its ENIs
|
||||
// and CreateListener opens listener ports on it; otherwise the ENIs fall
|
||||
// back to the VPC default SG and inbound listener traffic is dropped. When
|
||||
// the caller supplies SGs they replace the managed SG and own the rules.
|
||||
eniGroups := securityGroups
|
||||
if lbType == LoadBalancerTypeNetwork {
|
||||
if lbType == LoadBalancerTypeNetwork && len(securityGroups) == 0 {
|
||||
sgID, sgErr := s.createNLBManagedSG(lbID, lbArn, subnets[0], accountID)
|
||||
if sgErr != nil {
|
||||
slog.Error("CreateLoadBalancer: failed to create managed NLB SG", "lbId", lbID, "err", sgErr)
|
||||
|
||||
@@ -64,8 +64,11 @@ func (s *ELBv2ServiceImpl) SetSecurityGroups(input *elbv2.SetSecurityGroupsInput
|
||||
return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
|
||||
}
|
||||
|
||||
// NLBs do not support security groups (mirrors CreateLoadBalancer).
|
||||
if lb.Type == LoadBalancerTypeNetwork {
|
||||
// SGs are fixed at create time on NLBs: they can be replaced on an NLB created
|
||||
// with SGs, but not added to one created without (it carries the managed SG
|
||||
// instead). ALBs always allow SetSecurityGroups. The empty-input case is
|
||||
// already rejected above, so this never strips an SG-NLB to zero.
|
||||
if lb.Type == LoadBalancerTypeNetwork && len(lb.SecurityGroups) == 0 {
|
||||
return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
|
||||
}
|
||||
|
||||
@@ -76,6 +79,9 @@ func (s *ELBv2ServiceImpl) SetSecurityGroups(input *elbv2.SetSecurityGroupsInput
|
||||
}
|
||||
sgs = append(sgs, *sg)
|
||||
}
|
||||
if len(sgs) > maxLBSecurityGroups {
|
||||
return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
|
||||
}
|
||||
|
||||
// Re-attach to each ENI; failure aborts before the record is persisted.
|
||||
if s.VPCService != nil {
|
||||
|
||||
@@ -126,6 +126,40 @@ func TestSetSecurityGroups_NLBRejected(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorELBv2InvalidConfigurationRequest)
|
||||
}
|
||||
|
||||
func TestSetSecurityGroups_NLBWithSGs_Replaces(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("sg-nlb-repl"),
|
||||
Type: aws.String("network"),
|
||||
SecurityGroups: aws.StringSlice([]string{"sg-orig"}),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
arn := *out.LoadBalancers[0].LoadBalancerArn
|
||||
|
||||
res, err := svc.SetSecurityGroups(&elbv2.SetSecurityGroupsInput{
|
||||
LoadBalancerArn: aws.String(arn),
|
||||
SecurityGroups: aws.StringSlice([]string{"sg-new1", "sg-new2"}),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"sg-new1", "sg-new2"}, aws.StringValueSlice(res.SecurityGroupIds))
|
||||
|
||||
rec, err := svc.store.GetLoadBalancerByArn(arn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"sg-new1", "sg-new2"}, rec.SecurityGroups)
|
||||
}
|
||||
|
||||
func TestSetSecurityGroups_TooManyRejected(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
arn := createLBArn(t, svc, "sg-too-many")
|
||||
|
||||
_, err := svc.SetSecurityGroups(&elbv2.SetSecurityGroupsInput{
|
||||
LoadBalancerArn: aws.String(arn),
|
||||
SecurityGroups: aws.StringSlice([]string{"sg-1", "sg-2", "sg-3", "sg-4", "sg-5", "sg-6"}),
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorELBv2InvalidConfigurationRequest)
|
||||
}
|
||||
|
||||
func TestSetSecurityGroups_CrossAccount(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
arn := createLBArn(t, svc, "sg-xacct")
|
||||
|
||||
@@ -17,11 +17,15 @@ func nlbManagedSGName(lbID string) string {
|
||||
return "spinifex-nlb-" + lbID
|
||||
}
|
||||
|
||||
// lbENIGroups returns the SGs to attach to an LB's data-plane ENIs: the managed SG
|
||||
// for NLBs (customer SGs are rejected), or customer SGs for ALBs. Shared by the
|
||||
// create-time ENI loop and SetSubnets relaunch so both paths attach the same SG.
|
||||
// lbENIGroups returns the SGs to attach to an LB's data-plane ENIs: customer SGs
|
||||
// when present (ALBs always, NLBs that were created with SGs), else the managed SG
|
||||
// for an NLB created without SGs. Shared by the create-time ENI loop and SetSubnets
|
||||
// relaunch so both paths attach the same SG.
|
||||
func lbENIGroups(lb *LoadBalancerRecord) []string {
|
||||
if lb.Type == LoadBalancerTypeNetwork {
|
||||
if len(lb.SecurityGroups) > 0 {
|
||||
return lb.SecurityGroups
|
||||
}
|
||||
if lb.NLBManagedSGID != "" {
|
||||
return []string{lb.NLBManagedSGID}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,55 @@ func TestCreateNLB_MintsManagedSGAttachedToENI(t *testing.T) {
|
||||
assert.Equal(t, rec.NLBManagedSGID, *eni.Groups[0].GroupId)
|
||||
}
|
||||
|
||||
// TestLBENIGroups_Precedence checks the ENI-group selector: an NLB prefers its
|
||||
// customer SGs over the managed SG, falls back to the managed SG without them, and
|
||||
// an ALB always uses its customer SGs.
|
||||
func TestLBENIGroups_Precedence(t *testing.T) {
|
||||
assert.Equal(t, []string{"sg-a"}, lbENIGroups(&LoadBalancerRecord{
|
||||
Type: LoadBalancerTypeNetwork, SecurityGroups: []string{"sg-a"}, NLBManagedSGID: "sg-mgd",
|
||||
}))
|
||||
assert.Equal(t, []string{"sg-mgd"}, lbENIGroups(&LoadBalancerRecord{
|
||||
Type: LoadBalancerTypeNetwork, NLBManagedSGID: "sg-mgd",
|
||||
}))
|
||||
assert.Equal(t, []string{"sg-x"}, lbENIGroups(&LoadBalancerRecord{
|
||||
Type: LoadBalancerTypeApplication, SecurityGroups: []string{"sg-x"},
|
||||
}))
|
||||
}
|
||||
|
||||
// TestCreateNLB_WithCustomerSGs_AttachesThemNoManagedSG verifies an NLB created
|
||||
// with customer SGs joins those SGs on its ENI and skips the managed SG entirely;
|
||||
// the caller then owns the listener-port ingress rules.
|
||||
func TestCreateNLB_WithCustomerSGs_AttachesThemNoManagedSG(t *testing.T) {
|
||||
svc, vpcSvc := setupTestServiceWithVPC(t)
|
||||
subnetID, vpcID := firstSubnet(t, vpcSvc)
|
||||
|
||||
sgOut, err := vpcSvc.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{
|
||||
GroupName: aws.String("nlb-customer-sg"),
|
||||
Description: aws.String("NLB ingress"),
|
||||
VpcId: aws.String(vpcID),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
sgID := *sgOut.GroupId
|
||||
|
||||
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("nlb-customer-sg"),
|
||||
Type: aws.String("network"),
|
||||
Subnets: []*string{aws.String(subnetID)},
|
||||
SecurityGroups: []*string{aws.String(sgID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, err := svc.store.GetLoadBalancerByArn(*out.LoadBalancers[0].LoadBalancerArn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{sgID}, rec.SecurityGroups)
|
||||
assert.Empty(t, rec.NLBManagedSGID, "NLB with customer SGs must not mint a managed SG")
|
||||
|
||||
eni := managedENI(t, vpcSvc)
|
||||
require.NotNil(t, eni)
|
||||
require.Len(t, eni.Groups, 1, "NLB ENI must join exactly the customer SG")
|
||||
assert.Equal(t, sgID, *eni.Groups[0].GroupId)
|
||||
}
|
||||
|
||||
// TestCreateALB_NoManagedSG verifies the managed-SG behavior is NLB-only: an ALB
|
||||
// keeps the existing customer-SG / default-SG semantics.
|
||||
func TestCreateALB_NoManagedSG(t *testing.T) {
|
||||
|
||||
@@ -134,13 +134,32 @@ func TestCreateLoadBalancer_NetworkType(t *testing.T) {
|
||||
assert.Equal(t, "active", *lb.State.Code)
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_NetworkType_RejectsSecurityGroups(t *testing.T) {
|
||||
func TestCreateLoadBalancer_NetworkType_AcceptsSecurityGroups(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("nlb-with-sg"),
|
||||
Type: aws.String("network"),
|
||||
SecurityGroups: []*string{aws.String("sg-111")},
|
||||
SecurityGroups: []*string{aws.String("sg-111"), aws.String("sg-222")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, err := svc.store.GetLoadBalancerByName("nlb-with-sg", testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"sg-111", "sg-222"}, rec.SecurityGroups)
|
||||
assert.Empty(t, rec.NLBManagedSGID, "NLB created with customer SGs must not mint a managed SG")
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_NetworkType_RejectsTooManySecurityGroups(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("nlb-too-many-sg"),
|
||||
Type: aws.String("network"),
|
||||
SecurityGroups: []*string{
|
||||
aws.String("sg-1"), aws.String("sg-2"), aws.String("sg-3"),
|
||||
aws.String("sg-4"), aws.String("sg-5"), aws.String("sg-6"),
|
||||
},
|
||||
}, testAccountID)
|
||||
|
||||
assert.Error(t, err)
|
||||
|
||||
@@ -10,6 +10,9 @@ const (
|
||||
LoadBalancerTypeApplication = "application"
|
||||
LoadBalancerTypeNetwork = "network"
|
||||
|
||||
// maxLBSecurityGroups is the AWS cap on security groups per load balancer.
|
||||
maxLBSecurityGroups = 5
|
||||
|
||||
// LoadBalancer schemes
|
||||
SchemeInternetFacing = "internet-facing"
|
||||
SchemeInternal = "internal"
|
||||
@@ -116,9 +119,10 @@ type LoadBalancerRecord struct {
|
||||
State string `json:"state"` // "provisioning", "active", "failed"
|
||||
VpcId string `json:"vpc_id"`
|
||||
SecurityGroups []string `json:"security_groups"`
|
||||
// NLBManagedSGID is the managed SG minted for NLBs (customer SGs are rejected).
|
||||
// Attached to every LB ENI; listener-port ingress is authorized on it.
|
||||
// Not surfaced by DescribeLoadBalancers.
|
||||
// NLBManagedSGID is the managed SG minted for an NLB created without customer
|
||||
// SGs; attached to every LB ENI with listener-port ingress authorized on it.
|
||||
// Empty when the NLB was created with customer SGs (which replace it). Not
|
||||
// surfaced by DescribeLoadBalancers.
|
||||
NLBManagedSGID string `json:"nlb_managed_sg_id,omitempty"`
|
||||
// NLBIngressCIDRs overrides the scheme-based default client CIDRs that
|
||||
// listener ports are opened to on NLBManagedSGID. Empty ⇒ default
|
||||
|
||||
Reference in New Issue
Block a user