From 52e8e26262b03d491e0ec8002c62957be9a9e7ff Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Mon, 12 Oct 2026 01:09:02 +0530 Subject: [PATCH 1/2] fix(azure): bastion subnet name check and public IP in-use message names the resource id --- server/azure/bastion/bastion_ops.go | 14 ++++ server/azure/bastion/subnet_name_test.go | 37 ++++++++++ server/azure/vnet/handler.go | 10 ++- .../vnet/public_ip_delete_message_test.go | 74 +++++++++++++++++++ 4 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 server/azure/bastion/subnet_name_test.go create mode 100644 server/azure/vnet/public_ip_delete_message_test.go diff --git a/server/azure/bastion/bastion_ops.go b/server/azure/bastion/bastion_ops.go index 44bf124bf..64d3417bb 100644 --- a/server/azure/bastion/bastion_ops.go +++ b/server/azure/bastion/bastion_ops.go @@ -2,11 +2,15 @@ package bastion import ( "net/http" + "strings" "github.com/stackshy/cloudemu/v2/server/wire/azurearm" bastiondriver "github.com/stackshy/cloudemu/v2/services/bastion/driver" ) +// bastionSubnetName is the only subnet name ARM accepts for a Bastion host. +const bastionSubnetName = "AzureBastionSubnet" + // createOrUpdateBastionHost handles PUT .../bastionHosts/{name}. The whole host // arrives in one body and fully REPLACES the stored state, matching ARM's // CreateOrUpdate semantics. BastionHosts.CreateOrUpdate is an LRO; returning the @@ -20,6 +24,16 @@ func (h *Handler) createOrUpdateBastionHost(w http.ResponseWriter, r *http.Reque host := buildBastionHost(&body) + for i := range host.IPConfigurations { + subnet := host.IPConfigurations[i].SubnetID + if subnet != "" && subnet[strings.LastIndex(subnet, "/")+1:] != bastionSubnetName { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidParameter", + "Subnet "+subnet+" used by Bastion host must be named "+bastionSubnetName+".") + + return + } + } + stored, created, err := h.hosts.CreateOrUpdateBastionHost(r.Context(), rp.ResourceGroup, rp.ResourceName, host) if err != nil { azurearm.WriteCErr(w, err) diff --git a/server/azure/bastion/subnet_name_test.go b/server/azure/bastion/subnet_name_test.go new file mode 100644 index 000000000..1851d1ad3 --- /dev/null +++ b/server/azure/bastion/subnet_name_test.go @@ -0,0 +1,37 @@ +package bastion_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" +) + +// TestBastionRejectsWrongSubnetName proves a Bastion host whose ipConfiguration +// subnet is not named AzureBastionSubnet is refused with a 400. +func TestBastionRejectsWrongSubnetName(t *testing.T) { + ts := newServer(t) + c := bastionClient(t, ts) + + body := sampleHost() + body.Properties.IPConfigurations[0].Properties.Subnet.ID = to.Ptr( + "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vnet/subnets/default") + + _, err := c.BeginCreateOrUpdate(context.Background(), testRG, hostName, body, nil) + + var respErr *azcore.ResponseError + if !errors.As(err, &respErr) { + t.Fatalf("want ResponseError, got %v", err) + } + + if respErr.StatusCode != 400 || respErr.ErrorCode != "InvalidParameter" { + t.Errorf("got %d %s, want 400 InvalidParameter", respErr.StatusCode, respErr.ErrorCode) + } + + if !strings.Contains(respErr.Error(), "AzureBastionSubnet") { + t.Errorf("message should mention AzureBastionSubnet: %v", respErr) + } +} diff --git a/server/azure/vnet/handler.go b/server/azure/vnet/handler.go index b55eb158f..b82095ea2 100644 --- a/server/azure/vnet/handler.go +++ b/server/azure/vnet/handler.go @@ -1708,17 +1708,19 @@ func (h *Handler) deletePublicIP(w http.ResponseWriter, r *http.Request, rp azur id := azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, typePublicIP, rp.ResourceName) if h.publicIPConfigurationRef(r.Context(), rp.Subscription, id) != nil { - azurearm.WriteError(w, http.StatusBadRequest, "PublicIPAddressCannotBeDeleted", - "public IP "+rp.ResourceName+" is still referenced by a network interface ipConfiguration") + azurearm.WriteError(w, http.StatusBadRequest, "PublicIPAddressInUse", + "Public IP address "+id+" is in use by a network interface ipConfiguration and cannot be deleted.") return } if err := h.net.ReleaseAddress(r.Context(), info.AllocationID); err != nil { // A public IP still bound to a NIC/NAT gateway: ARM answers 400 with - // this specific code, not the generic 409 WriteCErr would emit. + // this specific code, naming the ARM resource id (never the internal + // allocation id), not the generic 409 WriteCErr would emit. if cerrors.IsFailedPrecondition(err) { - azurearm.WriteError(w, http.StatusBadRequest, "PublicIPAddressCannotBeDeleted", cerrors.Message(err)) + azurearm.WriteError(w, http.StatusBadRequest, "PublicIPAddressInUse", + "Public IP address "+id+" is still in use and cannot be deleted.") return } diff --git a/server/azure/vnet/public_ip_delete_message_test.go b/server/azure/vnet/public_ip_delete_message_test.go new file mode 100644 index 000000000..cf9a15687 --- /dev/null +++ b/server/azure/vnet/public_ip_delete_message_test.go @@ -0,0 +1,74 @@ +package vnet_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork" +) + +// TestSDKPublicIPDeleteInUseNamesResource checks the error for deleting a +// NAT-gateway-bound public IP is a 400 PublicIPAddressInUse naming the ARM +// resource id, not the internal allocation id. +func TestSDKPublicIPDeleteInUseNamesResource(t *testing.T) { + ts := newVNetServer(t) + ctx := context.Background() + opts := clientOpts(ts) + + pips, err := armnetwork.NewPublicIPAddressesClient("sub-1", fakeCred{}, opts) + if err != nil { + t.Fatal(err) + } + + pp, err := pips.BeginCreateOrUpdate(ctx, "rg-1", "pip-busy", armnetwork.PublicIPAddress{ + Location: to.Ptr("eastus"), + SKU: &armnetwork.PublicIPAddressSKU{Name: to.Ptr(armnetwork.PublicIPAddressSKUNameStandard)}, + Properties: &armnetwork.PublicIPAddressPropertiesFormat{ + PublicIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethodStatic), + }, + }, nil) + if err != nil { + t.Fatalf("create pip: %v", err) + } + + pollDone(t, pp) + + pipID := "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/publicIPAddresses/pip-busy" + + nats, err := armnetwork.NewNatGatewaysClient("sub-1", fakeCred{}, opts) + if err != nil { + t.Fatal(err) + } + + np, err := nats.BeginCreateOrUpdate(ctx, "rg-1", "natgw-busy", armnetwork.NatGateway{ + Location: to.Ptr("eastus"), + SKU: &armnetwork.NatGatewaySKU{Name: to.Ptr(armnetwork.NatGatewaySKUNameStandard)}, + Properties: &armnetwork.NatGatewayPropertiesFormat{ + PublicIPAddresses: []*armnetwork.SubResource{{ID: to.Ptr(pipID)}}, + }, + }, nil) + if err != nil { + t.Fatalf("create nat: %v", err) + } + + pollDone(t, np) + + _, err = pips.BeginDelete(ctx, "rg-1", "pip-busy", nil) + + var respErr *azcore.ResponseError + if !errors.As(err, &respErr) { + t.Fatalf("want ResponseError, got %v", err) + } + + if respErr.StatusCode != 400 || respErr.ErrorCode != "PublicIPAddressInUse" { + t.Errorf("got %d %s, want 400 PublicIPAddressInUse", respErr.StatusCode, respErr.ErrorCode) + } + + if strings.Contains(respErr.Error(), "ipalloc-") || !strings.Contains(respErr.Error(), pipID) { + t.Errorf("message should name %s and not an internal id: %v", pipID, respErr) + } +} From 9d9f3dbcd016e278ede0b1e7c4068a0a72e3c556 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Mon, 12 Oct 2026 01:09:46 +0530 Subject: [PATCH 2/2] fix(azure-network): keep the PublicIPAddressCannotBeDeleted code Azure uses --- server/azure/vnet/handler.go | 8 ++++---- server/azure/vnet/public_ip_delete_message_test.go | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/azure/vnet/handler.go b/server/azure/vnet/handler.go index b82095ea2..2708e419d 100644 --- a/server/azure/vnet/handler.go +++ b/server/azure/vnet/handler.go @@ -1708,8 +1708,8 @@ func (h *Handler) deletePublicIP(w http.ResponseWriter, r *http.Request, rp azur id := azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, typePublicIP, rp.ResourceName) if h.publicIPConfigurationRef(r.Context(), rp.Subscription, id) != nil { - azurearm.WriteError(w, http.StatusBadRequest, "PublicIPAddressInUse", - "Public IP address "+id+" is in use by a network interface ipConfiguration and cannot be deleted.") + azurearm.WriteError(w, http.StatusBadRequest, "PublicIPAddressCannotBeDeleted", + "Public IP address "+id+" can not be deleted since it is still allocated to a network interface ipConfiguration.") return } @@ -1719,8 +1719,8 @@ func (h *Handler) deletePublicIP(w http.ResponseWriter, r *http.Request, rp azur // this specific code, naming the ARM resource id (never the internal // allocation id), not the generic 409 WriteCErr would emit. if cerrors.IsFailedPrecondition(err) { - azurearm.WriteError(w, http.StatusBadRequest, "PublicIPAddressInUse", - "Public IP address "+id+" is still in use and cannot be deleted.") + azurearm.WriteError(w, http.StatusBadRequest, "PublicIPAddressCannotBeDeleted", + "Public IP address "+id+" can not be deleted since it is still allocated to a resource.") return } diff --git a/server/azure/vnet/public_ip_delete_message_test.go b/server/azure/vnet/public_ip_delete_message_test.go index cf9a15687..8ec44cff9 100644 --- a/server/azure/vnet/public_ip_delete_message_test.go +++ b/server/azure/vnet/public_ip_delete_message_test.go @@ -12,7 +12,7 @@ import ( ) // TestSDKPublicIPDeleteInUseNamesResource checks the error for deleting a -// NAT-gateway-bound public IP is a 400 PublicIPAddressInUse naming the ARM +// NAT-gateway-bound public IP is a 400 PublicIPAddressCannotBeDeleted naming the ARM // resource id, not the internal allocation id. func TestSDKPublicIPDeleteInUseNamesResource(t *testing.T) { ts := newVNetServer(t) @@ -64,8 +64,8 @@ func TestSDKPublicIPDeleteInUseNamesResource(t *testing.T) { t.Fatalf("want ResponseError, got %v", err) } - if respErr.StatusCode != 400 || respErr.ErrorCode != "PublicIPAddressInUse" { - t.Errorf("got %d %s, want 400 PublicIPAddressInUse", respErr.StatusCode, respErr.ErrorCode) + if respErr.StatusCode != 400 || respErr.ErrorCode != "PublicIPAddressCannotBeDeleted" { + t.Errorf("got %d %s, want 400 PublicIPAddressCannotBeDeleted", respErr.StatusCode, respErr.ErrorCode) } if strings.Contains(respErr.Error(), "ipalloc-") || !strings.Contains(respErr.Error(), pipID) {