From 2f1822a45dc07795d9747c207817406f1a83dc58 Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:59:00 +0100 Subject: [PATCH 1/8] NSX: add native route-based site-to-site VPN support --- .../element/Site2SiteVpnServiceProvider.java | 44 + .../com/cloud/network/nsx/NsxService.java | 12 + .../network/nsx/NsxVpnGatewayResult.java | 36 + .../java/org/apache/cloudstack/NsxAnswer.java | 9 + .../api/CreateNsxVpnConnectionCommand.java | 171 ++++ .../agent/api/CreateNsxVpnGatewayCommand.java | 63 ++ .../api/DeleteNsxVpnConnectionCommand.java | 64 ++ .../agent/api/DeleteNsxVpnGatewayCommand.java | 57 ++ .../api/GetNsxVpnSessionStatusCommand.java | 64 ++ .../UpdateNsxVpnConnectionStateCommand.java | 72 ++ .../cloudstack/resource/NsxResource.java | 196 +++- .../cloudstack/service/NsxApiClient.java | 960 +++++++++++++++++- .../apache/cloudstack/service/NsxElement.java | 327 +++++- .../cloudstack/service/NsxServiceImpl.java | 270 ++++- .../cloudstack/utils/NsxControllerUtils.java | 55 +- .../apache/cloudstack/utils/NsxHelper.java | 20 + .../cloudstack/utils/NsxVpnCryptoUtils.java | 181 ++++ .../cloudstack/resource/NsxResourceTest.java | 134 +++ .../cloudstack/service/NsxApiClientTest.java | 255 +++++ .../cloudstack/service/NsxElementTest.java | 503 +++++++++ .../service/NsxServiceImplTest.java | 80 ++ .../utils/NsxControllerUtilsTest.java | 25 + .../cloudstack/utils/NsxHelperTest.java | 53 + .../utils/NsxVpnCryptoUtilsTest.java | 174 ++++ .../network/vpn/Site2SiteVpnManagerImpl.java | 209 +++- .../vpn/Site2SiteVpnManagerImplTest.java | 180 +++- 26 files changed, 4179 insertions(+), 35 deletions(-) create mode 100644 api/src/main/java/com/cloud/network/nsx/NsxVpnGatewayResult.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnConnectionCommand.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnGatewayCommand.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnConnectionCommand.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnGatewayCommand.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/GetNsxVpnSessionStatusCommand.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/UpdateNsxVpnConnectionStateCommand.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxVpnCryptoUtils.java create mode 100644 plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxHelperTest.java create mode 100644 plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxVpnCryptoUtilsTest.java diff --git a/api/src/main/java/com/cloud/network/element/Site2SiteVpnServiceProvider.java b/api/src/main/java/com/cloud/network/element/Site2SiteVpnServiceProvider.java index dd451324a72e..40829a5c5a7a 100644 --- a/api/src/main/java/com/cloud/network/element/Site2SiteVpnServiceProvider.java +++ b/api/src/main/java/com/cloud/network/element/Site2SiteVpnServiceProvider.java @@ -17,11 +17,55 @@ package com.cloud.network.element; import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.IpAddress; +import com.cloud.network.Site2SiteCustomerGateway; import com.cloud.network.Site2SiteVpnConnection; +import com.cloud.network.Site2SiteVpnGateway; +import com.cloud.network.vpc.Vpc; import com.cloud.utils.component.Adapter; public interface Site2SiteVpnServiceProvider extends Adapter { + default void validateSite2SiteVpnCustomerGateway(Site2SiteCustomerGateway customerGateway) { + } + boolean startSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUnavailableException; boolean stopSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUnavailableException; + + /** + * Permanently removes a provider-side connection. This is distinct from stop: providers + * may disable a tunnel while retaining its profiles for an immediate reconnect, but deletion + * must remove all objects owned by the CloudStack connection. + */ + default boolean deleteSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUnavailableException { + return stopSite2SiteVpn(conn); + } + + /** + * Lets the provider supply the public IP the VPN gateway should terminate on, instead of the + * VPC source NAT IP. Providers that terminate VPN on an external gateway (e.g. NSX Tier-1) + * acquire and return a dedicated IP here; requestedIp, when not null, is the IP the caller + * asked for and must be validated by the provider. Returning null means the provider has no + * preference and the manager falls back to the default IP selection. + */ + default IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { + return null; + } + + /** + * Counterpart of {@link #acquireVpnGatewayIp(Vpc, IpAddress)}: invoked when a VPN gateway is + * deleted so the provider can tear down external VPN resources and release the gateway IP if + * it was acquired by the provider. + */ + default void releaseVpnGatewayIp(Site2SiteVpnGateway gateway) { + } + + /** + * Identifies a gateway previously owned by this provider. This is used during teardown when + * an offering has been edited since the gateway was created and the current service map no + * longer advertises the provider. + */ + default boolean ownsVpnGateway(Site2SiteVpnGateway gateway) { + return false; + } } diff --git a/api/src/main/java/com/cloud/network/nsx/NsxService.java b/api/src/main/java/com/cloud/network/nsx/NsxService.java index 1adb7461cc09..17dcd7465cf5 100644 --- a/api/src/main/java/com/cloud/network/nsx/NsxService.java +++ b/api/src/main/java/com/cloud/network/nsx/NsxService.java @@ -16,6 +16,8 @@ // under the License. package com.cloud.network.nsx; +import java.util.List; + import org.apache.cloudstack.framework.config.ConfigKey; import com.cloud.network.IpAddress; @@ -35,4 +37,14 @@ public interface NsxService { boolean createVpcNetwork(Long zoneId, long accountId, long domainId, Long vpcId, String vpcName, boolean sourceNatEnabled); boolean updateVpcSourceNatIp(Vpc vpc, IpAddress address); String getSegmentId(long domainId, long accountId, long zoneId, Long vpcId, long networkId); + + NsxVpnGatewayResult createVpnGateway(Vpc vpc, String localEndpointIp); + boolean deleteVpnGateway(Vpc vpc); + boolean createVpnConnection(Vpc vpc, String connectionUuid, String peerAddress, String psk, + String ikePolicy, String espPolicy, Long ikeLifetime, Long espLifetime, + boolean dpdEnabled, String ikeVersion, boolean passive, List peerCidrs, + String vtiLocalIp, String vtiPeerIp, int vtiPrefixLength, String localEndpointIp); + boolean deleteVpnConnection(Vpc vpc, String connectionUuid); + boolean updateVpnConnectionState(Vpc vpc, String connectionUuid, boolean enabled); + String getVpnConnectionStatus(Vpc vpc, String connectionUuid); } diff --git a/api/src/main/java/com/cloud/network/nsx/NsxVpnGatewayResult.java b/api/src/main/java/com/cloud/network/nsx/NsxVpnGatewayResult.java new file mode 100644 index 000000000000..c3b71ee2fbcf --- /dev/null +++ b/api/src/main/java/com/cloud/network/nsx/NsxVpnGatewayResult.java @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.network.nsx; + +public class NsxVpnGatewayResult { + + private final boolean successful; + private final boolean endpointMayBeInUse; + + public NsxVpnGatewayResult(boolean successful, boolean endpointMayBeInUse) { + this.successful = successful; + this.endpointMayBeInUse = endpointMayBeInUse; + } + + public boolean isSuccessful() { + return successful; + } + + public boolean isEndpointMayBeInUse() { + return endpointMayBeInUse; + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/NsxAnswer.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/NsxAnswer.java index a667adda7945..f2a629fafd84 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/NsxAnswer.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/NsxAnswer.java @@ -22,6 +22,7 @@ public class NsxAnswer extends Answer { private boolean objectExists; + private boolean endpointMayBeInUse; public NsxAnswer(final Command command, final boolean success, final String details) { super(command, success, details); @@ -38,4 +39,12 @@ public boolean isObjectExistent() { public void setObjectExists(boolean objectExisted) { this.objectExists = objectExisted; } + + public boolean isEndpointMayBeInUse() { + return endpointMayBeInUse; + } + + public void setEndpointMayBeInUse(boolean endpointMayBeInUse) { + this.endpointMayBeInUse = endpointMayBeInUse; + } } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnConnectionCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnConnectionCommand.java new file mode 100644 index 000000000000..050e475cd828 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnConnectionCommand.java @@ -0,0 +1,171 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.agent.api; + +import java.util.List; +import java.util.Objects; + +import com.cloud.agent.api.LogLevel; + +public class CreateNsxVpnConnectionCommand extends NsxCommand { + + private Long vpcId; + private String vpcName; + private String connectionUuid; + private String peerAddress; + @LogLevel(LogLevel.Log4jLevel.Off) + private String psk; + private String ikePolicy; + private String espPolicy; + private Long ikeLifetime; + private Long espLifetime; + private boolean dpdEnabled; + private String ikeVersion; + private boolean passive; + private List peerCidrs; + private String vtiLocalIp; + private String vtiPeerIp; + private int vtiPrefixLength; + private String vpcCidr; + private String localEndpointIp; + + public CreateNsxVpnConnectionCommand(long domainId, long accountId, long zoneId, + Long vpcId, String vpcName, String connectionUuid, String peerAddress, + String psk, String ikePolicy, String espPolicy, Long ikeLifetime, + Long espLifetime, boolean dpdEnabled, String ikeVersion, boolean passive, + List peerCidrs, String vtiLocalIp, String vtiPeerIp, + int vtiPrefixLength, String vpcCidr, String localEndpointIp) { + super(domainId, accountId, zoneId); + this.vpcId = vpcId; + this.vpcName = vpcName; + this.connectionUuid = connectionUuid; + this.peerAddress = peerAddress; + this.psk = psk; + this.ikePolicy = ikePolicy; + this.espPolicy = espPolicy; + this.ikeLifetime = ikeLifetime; + this.espLifetime = espLifetime; + this.dpdEnabled = dpdEnabled; + this.ikeVersion = ikeVersion; + this.passive = passive; + this.peerCidrs = peerCidrs; + this.vtiLocalIp = vtiLocalIp; + this.vtiPeerIp = vtiPeerIp; + this.vtiPrefixLength = vtiPrefixLength; + this.vpcCidr = vpcCidr; + this.localEndpointIp = localEndpointIp; + } + + public Long getVpcId() { + return vpcId; + } + + public String getVpcName() { + return vpcName; + } + + public String getConnectionUuid() { + return connectionUuid; + } + + public String getPeerAddress() { + return peerAddress; + } + + public String getPsk() { + return psk; + } + + public String getIkePolicy() { + return ikePolicy; + } + + public String getEspPolicy() { + return espPolicy; + } + + public Long getIkeLifetime() { + return ikeLifetime; + } + + public Long getEspLifetime() { + return espLifetime; + } + + public boolean isDpdEnabled() { + return dpdEnabled; + } + + public String getIkeVersion() { + return ikeVersion; + } + + public boolean isPassive() { + return passive; + } + + public List getPeerCidrs() { + return peerCidrs; + } + + public String getVtiLocalIp() { + return vtiLocalIp; + } + + public String getVtiPeerIp() { + return vtiPeerIp; + } + + public int getVtiPrefixLength() { + return vtiPrefixLength; + } + + public String getVpcCidr() { + return vpcCidr; + } + + public String getLocalEndpointIp() { + return localEndpointIp; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass() || !super.equals(o)) { + return false; + } + CreateNsxVpnConnectionCommand that = (CreateNsxVpnConnectionCommand) o; + return dpdEnabled == that.dpdEnabled && passive == that.passive && vtiPrefixLength == that.vtiPrefixLength + && Objects.equals(vpcId, that.vpcId) && Objects.equals(vpcName, that.vpcName) + && Objects.equals(connectionUuid, that.connectionUuid) && Objects.equals(peerAddress, that.peerAddress) + && Objects.equals(psk, that.psk) && Objects.equals(ikePolicy, that.ikePolicy) + && Objects.equals(espPolicy, that.espPolicy) && Objects.equals(ikeLifetime, that.ikeLifetime) + && Objects.equals(espLifetime, that.espLifetime) && Objects.equals(ikeVersion, that.ikeVersion) + && Objects.equals(peerCidrs, that.peerCidrs) && Objects.equals(vtiLocalIp, that.vtiLocalIp) + && Objects.equals(vtiPeerIp, that.vtiPeerIp) && Objects.equals(vpcCidr, that.vpcCidr) + && Objects.equals(localEndpointIp, that.localEndpointIp); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), vpcId, vpcName, connectionUuid, peerAddress, psk, ikePolicy, espPolicy, + ikeLifetime, espLifetime, dpdEnabled, ikeVersion, passive, peerCidrs, vtiLocalIp, vtiPeerIp, + vtiPrefixLength, vpcCidr, localEndpointIp); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnGatewayCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnGatewayCommand.java new file mode 100644 index 000000000000..9a381f5cb4b9 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnGatewayCommand.java @@ -0,0 +1,63 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.agent.api; + +import java.util.Objects; + +public class CreateNsxVpnGatewayCommand extends NsxCommand { + + private Long vpcId; + private String vpcName; + private String localEndpointIp; + + public CreateNsxVpnGatewayCommand(long domainId, long accountId, long zoneId, + Long vpcId, String vpcName, String localEndpointIp) { + super(domainId, accountId, zoneId); + this.vpcId = vpcId; + this.vpcName = vpcName; + this.localEndpointIp = localEndpointIp; + } + + public Long getVpcId() { + return vpcId; + } + + public String getVpcName() { + return vpcName; + } + + public String getLocalEndpointIp() { + return localEndpointIp; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass() || !super.equals(o)) { + return false; + } + CreateNsxVpnGatewayCommand that = (CreateNsxVpnGatewayCommand) o; + return Objects.equals(vpcId, that.vpcId) && Objects.equals(vpcName, that.vpcName) && Objects.equals(localEndpointIp, that.localEndpointIp); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), vpcId, vpcName, localEndpointIp); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnConnectionCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnConnectionCommand.java new file mode 100644 index 000000000000..83b0644e695f --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnConnectionCommand.java @@ -0,0 +1,64 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.agent.api; + +import java.util.Objects; + +public class DeleteNsxVpnConnectionCommand extends NsxCommand { + + private Long vpcId; + private String vpcName; + private String connectionUuid; + + public DeleteNsxVpnConnectionCommand(long domainId, long accountId, long zoneId, + Long vpcId, String vpcName, String connectionUuid) { + super(domainId, accountId, zoneId); + this.vpcId = vpcId; + this.vpcName = vpcName; + this.connectionUuid = connectionUuid; + } + + public Long getVpcId() { + return vpcId; + } + + public String getVpcName() { + return vpcName; + } + + public String getConnectionUuid() { + return connectionUuid; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass() || !super.equals(o)) { + return false; + } + DeleteNsxVpnConnectionCommand that = (DeleteNsxVpnConnectionCommand) o; + return Objects.equals(vpcId, that.vpcId) && Objects.equals(vpcName, that.vpcName) + && Objects.equals(connectionUuid, that.connectionUuid); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), vpcId, vpcName, connectionUuid); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnGatewayCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnGatewayCommand.java new file mode 100644 index 000000000000..cb5a97f9d344 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnGatewayCommand.java @@ -0,0 +1,57 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.agent.api; + +import java.util.Objects; + +public class DeleteNsxVpnGatewayCommand extends NsxCommand { + + private Long vpcId; + private String vpcName; + + public DeleteNsxVpnGatewayCommand(long domainId, long accountId, long zoneId, + Long vpcId, String vpcName) { + super(domainId, accountId, zoneId); + this.vpcId = vpcId; + this.vpcName = vpcName; + } + + public Long getVpcId() { + return vpcId; + } + + public String getVpcName() { + return vpcName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass() || !super.equals(o)) { + return false; + } + DeleteNsxVpnGatewayCommand that = (DeleteNsxVpnGatewayCommand) o; + return Objects.equals(vpcId, that.vpcId) && Objects.equals(vpcName, that.vpcName); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), vpcId, vpcName); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/GetNsxVpnSessionStatusCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/GetNsxVpnSessionStatusCommand.java new file mode 100644 index 000000000000..0aed9e33f8f7 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/GetNsxVpnSessionStatusCommand.java @@ -0,0 +1,64 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.agent.api; + +import java.util.Objects; + +public class GetNsxVpnSessionStatusCommand extends NsxCommand { + + private Long vpcId; + private String vpcName; + private String connectionUuid; + + public GetNsxVpnSessionStatusCommand(long domainId, long accountId, long zoneId, + Long vpcId, String vpcName, String connectionUuid) { + super(domainId, accountId, zoneId); + this.vpcId = vpcId; + this.vpcName = vpcName; + this.connectionUuid = connectionUuid; + } + + public Long getVpcId() { + return vpcId; + } + + public String getVpcName() { + return vpcName; + } + + public String getConnectionUuid() { + return connectionUuid; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass() || !super.equals(o)) { + return false; + } + GetNsxVpnSessionStatusCommand that = (GetNsxVpnSessionStatusCommand) o; + return Objects.equals(vpcId, that.vpcId) && Objects.equals(vpcName, that.vpcName) + && Objects.equals(connectionUuid, that.connectionUuid); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), vpcId, vpcName, connectionUuid); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/UpdateNsxVpnConnectionStateCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/UpdateNsxVpnConnectionStateCommand.java new file mode 100644 index 000000000000..541065f86a04 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/UpdateNsxVpnConnectionStateCommand.java @@ -0,0 +1,72 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.agent.api; + +import java.util.Objects; + +public class UpdateNsxVpnConnectionStateCommand extends NsxCommand { + + private Long vpcId; + private String vpcName; + private String connectionUuid; + private boolean enabled; + + public UpdateNsxVpnConnectionStateCommand(long domainId, long accountId, long zoneId, + Long vpcId, String vpcName, String connectionUuid, + boolean enabled) { + super(domainId, accountId, zoneId); + this.vpcId = vpcId; + this.vpcName = vpcName; + this.connectionUuid = connectionUuid; + this.enabled = enabled; + } + + public Long getVpcId() { + return vpcId; + } + + public String getVpcName() { + return vpcName; + } + + public String getConnectionUuid() { + return connectionUuid; + } + + public boolean isEnabled() { + return enabled; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass() || !super.equals(o)) { + return false; + } + UpdateNsxVpnConnectionStateCommand that = (UpdateNsxVpnConnectionStateCommand) o; + return enabled == that.enabled && Objects.equals(vpcId, that.vpcId) + && Objects.equals(vpcName, that.vpcName) + && Objects.equals(connectionUuid, that.connectionUuid); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), vpcId, vpcName, connectionUuid, enabled); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java index 78a9363a5e49..a2e34a954798 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java @@ -28,6 +28,7 @@ import com.cloud.host.Host; import com.cloud.network.Network; import com.cloud.resource.ServerResource; +import com.cloud.utils.Pair; import com.cloud.utils.exception.CloudRuntimeException; import com.vmware.nsx.model.TransportZone; @@ -42,12 +43,18 @@ import org.apache.cloudstack.agent.api.CreateNsxSegmentCommand; import org.apache.cloudstack.agent.api.CreateNsxStaticNatCommand; import org.apache.cloudstack.agent.api.CreateNsxTier1GatewayCommand; +import org.apache.cloudstack.agent.api.CreateNsxVpnConnectionCommand; +import org.apache.cloudstack.agent.api.CreateNsxVpnGatewayCommand; import org.apache.cloudstack.agent.api.CreateOrUpdateNsxTier1NatRuleCommand; import org.apache.cloudstack.agent.api.DeleteNsxDistributedFirewallRulesCommand; import org.apache.cloudstack.agent.api.DeleteNsxLoadBalancerRuleCommand; import org.apache.cloudstack.agent.api.DeleteNsxSegmentCommand; import org.apache.cloudstack.agent.api.DeleteNsxNatRuleCommand; import org.apache.cloudstack.agent.api.DeleteNsxTier1GatewayCommand; +import org.apache.cloudstack.agent.api.DeleteNsxVpnConnectionCommand; +import org.apache.cloudstack.agent.api.DeleteNsxVpnGatewayCommand; +import org.apache.cloudstack.agent.api.GetNsxVpnSessionStatusCommand; +import org.apache.cloudstack.agent.api.UpdateNsxVpnConnectionStateCommand; import org.apache.cloudstack.service.NsxApiClient; import org.apache.cloudstack.utils.NsxControllerUtils; import org.apache.commons.collections.CollectionUtils; @@ -59,9 +66,21 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; public class NsxResource implements ServerResource { + + private static final int VPN_TIER1_LOCK_STRIPES = 256; + private static final Object[] VPN_TIER1_LOCKS = createVpnTier1Locks(); + + private static Object[] createVpnTier1Locks() { + Object[] locks = new Object[VPN_TIER1_LOCK_STRIPES]; + for (int i = 0; i < locks.length; i++) { + locks[i] = new Object(); + } + return locks; + } protected Logger logger = LogManager.getLogger(getClass()); private static final String DHCP_RELAY_CONFIGS_PATH_PREFIX = "/infra/dhcp-relay-configs"; @@ -132,6 +151,18 @@ public Answer executeRequest(Command cmd) { return executeRequest((DeleteNsxDistributedFirewallRulesCommand) cmd); } else if (cmd instanceof CreateNsxDistributedFirewallRulesCommand) { return executeRequest((CreateNsxDistributedFirewallRulesCommand) cmd); + } else if (cmd instanceof CreateNsxVpnGatewayCommand) { + return executeRequest((CreateNsxVpnGatewayCommand) cmd); + } else if (cmd instanceof DeleteNsxVpnGatewayCommand) { + return executeRequest((DeleteNsxVpnGatewayCommand) cmd); + } else if (cmd instanceof CreateNsxVpnConnectionCommand) { + return executeRequest((CreateNsxVpnConnectionCommand) cmd); + } else if (cmd instanceof DeleteNsxVpnConnectionCommand) { + return executeRequest((DeleteNsxVpnConnectionCommand) cmd); + } else if (cmd instanceof GetNsxVpnSessionStatusCommand) { + return executeRequest((GetNsxVpnSessionStatusCommand) cmd); + } else if (cmd instanceof UpdateNsxVpnConnectionStateCommand) { + return executeRequest((UpdateNsxVpnConnectionStateCommand) cmd); } else { return Answer.createUnsupportedCommandAnswer(cmd); } @@ -318,11 +349,14 @@ private Answer executeRequest(CreateNsxTier1GatewayCommand cmd) { private Answer executeRequest(DeleteNsxTier1GatewayCommand cmd) { String tier1Id = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), cmd.getZoneId(), cmd.getNetworkResourceId(), cmd.isResourceVpc()); String lbName = NsxControllerUtils.getLoadBalancerName(tier1Id); - try { - nsxApiClient.deleteLoadBalancer(lbName); - nsxApiClient.deleteTier1Gateway(tier1Id); - } catch (Exception e) { - return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + Object lock = getVpnTier1Lock(tier1Id); + synchronized (lock) { + try { + nsxApiClient.deleteLoadBalancer(lbName); + nsxApiClient.deleteTier1Gateway(tier1Id); + } catch (Exception e) { + return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + } } return new NsxAnswer(cmd, true, null); } @@ -488,6 +522,158 @@ private NsxAnswer executeRequest(DeleteNsxDistributedFirewallRulesCommand cmd) { return new NsxAnswer(cmd, true, null); } + private NsxAnswer executeRequest(CreateNsxVpnGatewayCommand cmd) { + String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), + cmd.getZoneId(), cmd.getVpcId(), true); + Object lock = getVpnTier1Lock(tier1GatewayName); + synchronized (lock) { + final boolean vpnServiceExisted; + try { + vpnServiceExisted = nsxApiClient.isVpnServicePresent(tier1GatewayName); + } catch (Exception e) { + logger.error(String.format("Failed to check the existing NSX VPN service on tier-1 gateway %s before creation: %s", + tier1GatewayName, e.getMessage())); + return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + } + if (vpnServiceExisted) { + NsxAnswer answer = new NsxAnswer(cmd, new CloudRuntimeException(String.format( + "An NSX VPN service already exists on tier-1 gateway %s; refusing to adopt or overwrite it", + tier1GatewayName))); + answer.setObjectExists(true); + return answer; + } + try { + nsxApiClient.createVpnService(tier1GatewayName, cmd.getLocalEndpointIp()); + } catch (Exception e) { + boolean endpointMayBeInUse = false; + try { + nsxApiClient.deleteVpnService(tier1GatewayName); + } catch (Exception rollbackException) { + endpointMayBeInUse = true; + logger.warn("Failed to roll back the newly-created NSX VPN service on tier-1 gateway {} after creation failed: {}", + tier1GatewayName, rollbackException.getMessage()); + } + logger.error(String.format("Failed to create the NSX VPN service on tier-1 gateway %s for VPC %s: %s", + tier1GatewayName, cmd.getVpcName(), e.getMessage())); + NsxAnswer answer = new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + answer.setEndpointMayBeInUse(endpointMayBeInUse); + return answer; + } + } + NsxAnswer answer = new NsxAnswer(cmd, true, null); + answer.setEndpointMayBeInUse(true); + return answer; + } + + private NsxAnswer executeRequest(DeleteNsxVpnGatewayCommand cmd) { + String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), + cmd.getZoneId(), cmd.getVpcId(), true); + Object lock = getVpnTier1Lock(tier1GatewayName); + synchronized (lock) { + try { + nsxApiClient.deleteVpnService(tier1GatewayName); + } catch (Exception e) { + logger.error(String.format("Failed to delete the NSX VPN service on tier-1 gateway %s for VPC %s: %s", + tier1GatewayName, cmd.getVpcName(), e.getMessage())); + return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + } + } + return new NsxAnswer(cmd, true, null); + } + + private NsxAnswer executeRequest(CreateNsxVpnConnectionCommand cmd) { + String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), + cmd.getZoneId(), cmd.getVpcId(), true); + Object lock = getVpnTier1Lock(tier1GatewayName); + synchronized (lock) { + boolean sessionCreated = false; + try { + // The requested VTI /30 is derived from the connection id. Fail closed when another + // session already owns its local address; silently choosing a different pair would make + // the peer route advertised by CloudStack disagree with the pair installed in NSX. + Set inUseVtiIps = nsxApiClient.getRouteBasedVpnSessionLocalVtiIps(tier1GatewayName, cmd.getConnectionUuid()); + if (inUseVtiIps.contains(cmd.getVtiLocalIp())) { + throw new CloudRuntimeException(String.format( + "The deterministic VTI address %s for VPN connection %s is already in use on tier-1 gateway %s", + cmd.getVtiLocalIp(), cmd.getConnectionUuid(), tier1GatewayName)); + } + Pair vtiAddresses = new Pair<>(cmd.getVtiLocalIp(), cmd.getVtiPeerIp()); + nsxApiClient.createRouteBasedVpnSession(tier1GatewayName, cmd.getConnectionUuid(), cmd.getPeerAddress(), + cmd.getPsk(), cmd.getIkePolicy(), cmd.getEspPolicy(), cmd.getIkeLifetime(), cmd.getEspLifetime(), + cmd.isDpdEnabled(), cmd.getIkeVersion(), cmd.isPassive(), vtiAddresses.first(), cmd.getVtiPrefixLength()); + sessionCreated = true; + nsxApiClient.addVpnConnectionRoutes(tier1GatewayName, cmd.getConnectionUuid(), cmd.getPeerCidrs(), + vtiAddresses.second(), cmd.getVpcCidr()); + // Applied here as well so that VPN gateways created before the exemptions existed, or whose + // tier-1 gained a source NAT rule afterwards, are corrected without recreating the gateway + nsxApiClient.ensureVpnNatExemptions(tier1GatewayName, cmd.getLocalEndpointIp()); + } catch (Exception e) { + if (sessionCreated) { + try { + nsxApiClient.rollbackVpnConnection(tier1GatewayName, cmd.getConnectionUuid()); + } catch (Exception rollbackException) { + logger.warn("Failed to roll back the partially created NSX VPN connection {}: {}", + cmd.getConnectionUuid(), rollbackException.getMessage()); + } + } + logger.error(String.format("Failed to create the NSX VPN connection %s on tier-1 gateway %s for VPC %s: %s", + cmd.getConnectionUuid(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); + return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + } + } + return new NsxAnswer(cmd, true, null); + } + + private NsxAnswer executeRequest(DeleteNsxVpnConnectionCommand cmd) { + String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), + cmd.getZoneId(), cmd.getVpcId(), true); + Object lock = getVpnTier1Lock(tier1GatewayName); + synchronized (lock) { + try { + nsxApiClient.deleteVpnConnection(tier1GatewayName, cmd.getConnectionUuid()); + } catch (Exception e) { + logger.error(String.format("Failed to delete the NSX VPN connection %s on tier-1 gateway %s for VPC %s: %s", + cmd.getConnectionUuid(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); + return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + } + } + return new NsxAnswer(cmd, true, null); + } + + private NsxAnswer executeRequest(UpdateNsxVpnConnectionStateCommand cmd) { + String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), + cmd.getZoneId(), cmd.getVpcId(), true); + Object lock = getVpnTier1Lock(tier1GatewayName); + synchronized (lock) { + try { + nsxApiClient.updateVpnConnectionState(tier1GatewayName, cmd.getConnectionUuid(), cmd.isEnabled()); + } catch (Exception e) { + logger.error(String.format("Failed to update the state of the NSX VPN connection %s on tier-1 gateway %s for VPC %s: %s", + cmd.getConnectionUuid(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); + return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + } + } + return new NsxAnswer(cmd, true, null); + } + + private Object getVpnTier1Lock(String tier1GatewayName) { + return VPN_TIER1_LOCKS[Math.floorMod(tier1GatewayName.hashCode(), VPN_TIER1_LOCKS.length)]; + } + + private NsxAnswer executeRequest(GetNsxVpnSessionStatusCommand cmd) { + String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), + cmd.getZoneId(), cmd.getVpcId(), true); + String status; + try { + status = nsxApiClient.getVpnSessionStatus(tier1GatewayName, cmd.getConnectionUuid()); + } catch (Exception e) { + logger.error(String.format("Failed to get the status of the NSX VPN connection %s on tier-1 gateway %s for VPC %s: %s", + cmd.getConnectionUuid(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); + return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + } + return new NsxAnswer(cmd, true, status); + } + @Override public boolean start() { return true; diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java index 4d78f2a0ab26..3250784e3d71 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java @@ -26,6 +26,9 @@ import com.vmware.nsx.model.TransportZone; import com.vmware.nsx.model.TransportZoneListResult; import com.vmware.nsx_policy.infra.DhcpRelayConfigs; +import com.vmware.nsx_policy.infra.IpsecVpnDpdProfiles; +import com.vmware.nsx_policy.infra.IpsecVpnIkeProfiles; +import com.vmware.nsx_policy.infra.IpsecVpnTunnelProfiles; import com.vmware.nsx_policy.infra.LbAppProfiles; import com.vmware.nsx_policy.infra.LbMonitorProfiles; import com.vmware.nsx_policy.infra.LbPools; @@ -41,7 +44,12 @@ import com.vmware.nsx_policy.infra.domains.security_policies.Rules; import com.vmware.nsx_policy.infra.sites.EnforcementPoints; import com.vmware.nsx_policy.infra.tier_0s.LocaleServices; +import com.vmware.nsx_policy.infra.tier_1s.IpsecVpnServices; +import com.vmware.nsx_policy.infra.tier_1s.ipsec_vpn_services.LocalEndpoints; +import com.vmware.nsx_policy.infra.tier_1s.ipsec_vpn_services.Sessions; +import com.vmware.nsx_policy.infra.tier_1s.ipsec_vpn_services.sessions.DetailedStatus; import com.vmware.nsx_policy.infra.tier_1s.nat.NatRules; +import com.vmware.nsx_policy.model.AggregateIPSecVpnSessionStatus; import com.vmware.nsx_policy.model.ApiError; import com.vmware.nsx_policy.model.DhcpRelayConfig; import com.vmware.nsx_policy.model.EnforcementPoint; @@ -49,6 +57,17 @@ import com.vmware.nsx_policy.model.Group; import com.vmware.nsx_policy.model.GroupListResult; import com.vmware.nsx_policy.model.ICMPTypeServiceEntry; +import com.vmware.nsx_policy.model.IPSecVpnDpdProfile; +import com.vmware.nsx_policy.model.IPSecVpnIkeProfile; +import com.vmware.nsx_policy.model.IPSecVpnLocalEndpoint; +import com.vmware.nsx_policy.model.IPSecVpnLocalEndpointListResult; +import com.vmware.nsx_policy.model.IPSecVpnService; +import com.vmware.nsx_policy.model.IPSecVpnSession; +import com.vmware.nsx_policy.model.IPSecVpnServiceListResult; +import com.vmware.nsx_policy.model.IPSecVpnSessionListResult; +import com.vmware.nsx_policy.model.IPSecVpnSessionStatusNsxt; +import com.vmware.nsx_policy.model.IPSecVpnTunnelInterface; +import com.vmware.nsx_policy.model.IPSecVpnTunnelProfile; import com.vmware.nsx_policy.model.L4PortSetServiceEntry; import com.vmware.nsx_policy.model.LBAppProfileListResult; import com.vmware.nsx_policy.model.LBIcmpMonitorProfile; @@ -66,13 +85,18 @@ import com.vmware.nsx_policy.model.PolicyNatRule; import com.vmware.nsx_policy.model.PolicyNatRuleListResult; import com.vmware.nsx_policy.model.PolicyGroupMemberDetails; +import com.vmware.nsx_policy.model.RouteBasedIPSecVpnSession; +import com.vmware.nsx_policy.model.RouterNexthop; import com.vmware.nsx_policy.model.Rule; import com.vmware.nsx_policy.model.SecurityPolicy; import com.vmware.nsx_policy.model.Segment; import com.vmware.nsx_policy.model.SegmentSubnet; import com.vmware.nsx_policy.model.ServiceListResult; import com.vmware.nsx_policy.model.Site; +import com.vmware.nsx_policy.model.StaticRoutesListResult; +import com.vmware.nsx_policy.model.Tag; import com.vmware.nsx_policy.model.Tier1; +import com.vmware.nsx_policy.model.TunnelInterfaceIPSubnet; import com.vmware.vapi.bindings.Service; import com.vmware.vapi.bindings.Structure; import com.vmware.vapi.bindings.StubConfiguration; @@ -89,12 +113,14 @@ import org.apache.cloudstack.resource.NsxLoadBalancerMember; import org.apache.cloudstack.resource.NsxNetworkRule; import org.apache.cloudstack.utils.NsxControllerUtils; +import org.apache.cloudstack.utils.NsxVpnCryptoUtils; import org.apache.commons.collections.CollectionUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.commons.lang3.BooleanUtils; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -114,6 +140,17 @@ import static org.apache.cloudstack.utils.NsxControllerUtils.getLoadBalancerAlgorithm; import static org.apache.cloudstack.utils.NsxControllerUtils.getActiveMonitorProfileName; import static org.apache.cloudstack.utils.NsxControllerUtils.getTier1GatewayName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnDpdProfileName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnEspProfileName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnIkeProfileName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnLocalEndpointName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnLocalEndpointNoSnatRuleName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnNoSnatRuleName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnNoSnatRuleNamePrefix; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnServiceName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnSessionName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnStaticRouteName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnStaticRouteNamePrefix; public class NsxApiClient { @@ -138,6 +175,27 @@ public class NsxApiClient { protected static final String TCP_MONITOR_PROFILE = "LBTcpMonitorProfile"; protected static final String ICMP_MONITOR_PROFILE = "LBIcmpMonitorProfile"; protected static final String NAT_ID = "USER"; + protected static final String IPSEC_VPN_IKE_PROFILES_PATH_PREFIX = "/infra/ipsec-vpn-ike-profiles/"; + protected static final String IPSEC_VPN_TUNNEL_PROFILES_PATH_PREFIX = "/infra/ipsec-vpn-tunnel-profiles/"; + protected static final String IPSEC_VPN_DPD_PROFILES_PATH_PREFIX = "/infra/ipsec-vpn-dpd-profiles/"; + // NSX resolves NAT rules scoped to a VTI only for a tunnel interface with this exact name (KB 435087) + protected static final String VPN_DEFAULT_TUNNEL_INTERFACE_NAME = "default-tunnel-interface"; + // NSX purges objects marked for deletion in a cycle that it documents as taking up to 5 minutes, + // and rejects recreating an object under the same path until then + protected static final int VPN_MARKED_FOR_DELETION_RETRIES = 24; + protected static final int VPN_MARKED_FOR_DELETION_RETRY_INTERVAL_SECS = 15; + // In on demand mode the probe interval is the idle time before a probe is sent, which NSX limits + // to 1-10 seconds (the 3-360 second range only applies to periodic probing) + protected static final long VPN_DPD_PROBE_INTERVAL_SECS = 10L; + protected static final long VPN_DPD_RETRY_COUNT = 10L; + // NSX evaluates NAT rules by ascending sequence number, so the traffic the VPN exempts from source + // NAT has to be matched before the catch all source NAT rule of the VPC + protected static final long VPN_NO_SNAT_SEQUENCE_NUMBER = 100L; + protected static final long CATCH_ALL_NAT_SEQUENCE_NUMBER = 1000L; + protected static final String VPN_ORIGINAL_SNAT_SEQUENCE_TAG_SCOPE = "cloudstack-vpn-original-snat-sequence"; + protected static final int NSX_MAX_TAGS = 30; + protected static final String VPN_SESSION_STATUS_UNKNOWN = "UNKNOWN"; + protected static final String VPN_SESSION_STATUS_NOT_FOUND = "NOT_FOUND"; private enum PoolAllocation { ROUTING, LB_SMALL, LB_MEDIUM, LB_LARGE, LB_XLARGE } @@ -151,7 +209,7 @@ private enum TransportType { OVERLAY, VLAN } private enum NatId { USER, INTERNAL, DEFAULT } - private enum NatAction {SNAT, DNAT, REFLEXIVE} + private enum NatAction {SNAT, DNAT, REFLEXIVE, NO_SNAT} private enum FirewallMatch { MATCH_INTERNAL_ADDRESS, @@ -242,6 +300,144 @@ public void createTier1NatRule(String tier1GatewayName, String natId, String nat natRulesService.patch(tier1GatewayName, natId, natRuleId, natPolicy); } + /** + * The IKE traffic the gateway originates from the local endpoint must keep that address as its + * source: the catch all source NAT rule would otherwise rewrite it to the VPC source NAT IP and the + * peer, which only knows the local endpoint address, would ignore the packets. Applied whenever a + * VPN service or connection is created so that gateways predating this also get the exemption. + */ + public void ensureVpnNatExemptions(String tier1GatewayName, String localEndpointIp) { + demoteCatchAllSourceNatRule(tier1GatewayName); + String localEndpointNoSnatRuleName = getVpnLocalEndpointNoSnatRuleName(getVpnServiceName(tier1GatewayName)); + NatRules natService = (NatRules) nsxService.apply(NatRules.class); + PolicyNatRule localEndpointNoSnatRule = new PolicyNatRule.Builder() + .setId(localEndpointNoSnatRuleName) + .setDisplayName(localEndpointNoSnatRuleName) + .setAction(NatAction.NO_SNAT.name()) + .setSourceNetwork(localEndpointIp) + .setSequenceNumber(VPN_NO_SNAT_SEQUENCE_NUMBER) + .setEnabled(true) + .build(); + natService.patch(tier1GatewayName, NatId.USER.name(), localEndpointNoSnatRuleName, localEndpointNoSnatRule); + } + + /** Existing CloudStack source NAT must be evaluated after the VPN no-SNAT rules. */ + private void demoteCatchAllSourceNatRule(String tier1GatewayName) { + NatRules natRulesService = (NatRules) nsxService.apply(NatRules.class); + try { + String ruleId = getCloudStackSourceNatRuleId(tier1GatewayName); + PolicyNatRule natRule = natRulesService.get(tier1GatewayName, NatId.USER.name(), ruleId); + if (!isCatchAllSourceNatRule(natRule)) { + return; + } + List tags = copyNatRuleTags(natRule); + Tag originalSequenceTag = findOriginalSnatSequenceTag(tags); + if (originalSequenceTag == null) { + if (tags.size() >= NSX_MAX_TAGS) { + throw new CloudRuntimeException(String.format( + "Cannot preserve the source NAT rule sequence of tier-1 gateway %s because the rule already has the maximum number of tags", + tier1GatewayName)); + } + long originalSequence = natRule.getSequenceNumber() == null ? 0L : natRule.getSequenceNumber(); + tags.add(new Tag.Builder() + .setScope(VPN_ORIGINAL_SNAT_SEQUENCE_TAG_SCOPE) + .setTag(String.valueOf(originalSequence)) + .build()); + } + logger.debug("Moving CloudStack source NAT rule {} on tier-1 gateway {} behind VPN no-SNAT rules", + ruleId, tier1GatewayName); + natRulesService.patch(tier1GatewayName, NatId.USER.name(), ruleId, + copyNatRule(natRule, CATCH_ALL_NAT_SEQUENCE_NUMBER, tags)); + } catch (NotFound e) { + logger.debug("CloudStack source NAT rule is absent on tier-1 gateway {}; no VPN NAT ordering change is required", + tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + throw new CloudRuntimeException(String.format( + "Failed to order the source NAT rules of tier-1 gateway %s before creating the NSX VPN exemptions: %s", + tier1GatewayName, ae.getErrorMessage()), error); + } + } + + void restoreSourceNatRuleSequence(String tier1GatewayName) { + NatRules natRulesService = (NatRules) nsxService.apply(NatRules.class); + try { + String ruleId = getCloudStackSourceNatRuleId(tier1GatewayName); + PolicyNatRule natRule = natRulesService.get(tier1GatewayName, NatId.USER.name(), ruleId); + List tags = copyNatRuleTags(natRule); + Tag originalSequenceTag = findOriginalSnatSequenceTag(tags); + if (originalSequenceTag == null) { + return; + } + long originalSequence; + try { + originalSequence = Long.parseLong(originalSequenceTag.getTag()); + } catch (NumberFormatException e) { + throw new CloudRuntimeException(String.format( + "Invalid saved source NAT sequence '%s' on tier-1 gateway %s", + originalSequenceTag.getTag(), tier1GatewayName), e); + } + tags.remove(originalSequenceTag); + natRulesService.patch(tier1GatewayName, NatId.USER.name(), ruleId, + copyNatRule(natRule, originalSequence, tags)); + } catch (NotFound e) { + logger.debug("CloudStack source NAT rule is absent on tier-1 gateway {}; no sequence restoration is required", + tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + throw new CloudRuntimeException(String.format( + "Failed to restore the source NAT rule order of tier-1 gateway %s after deleting the NSX VPN gateway: %s", + tier1GatewayName, ae.getErrorMessage()), error); + } + } + + private String getCloudStackSourceNatRuleId(String tier1GatewayName) { + return tier1GatewayName + "-NAT"; + } + + private List copyNatRuleTags(PolicyNatRule natRule) { + return natRule.getTags() == null ? new ArrayList<>() : new ArrayList<>(natRule.getTags()); + } + + private Tag findOriginalSnatSequenceTag(List tags) { + return tags.stream() + .filter(tag -> VPN_ORIGINAL_SNAT_SEQUENCE_TAG_SCOPE.equals(tag.getScope())) + .findFirst() + .orElse(null); + } + + private PolicyNatRule copyNatRule(PolicyNatRule natRule, long sequenceNumber, List tags) { + return new PolicyNatRule.Builder() + .setId(natRule.getId()) + .setDisplayName(natRule.getDisplayName()) + .setDescription(natRule.getDescription()) + .setAction(natRule.getAction()) + .setTranslatedNetwork(natRule.getTranslatedNetwork()) + .setTranslatedPorts(natRule.getTranslatedPorts()) + .setSourceNetwork(natRule.getSourceNetwork()) + .setDestinationNetwork(natRule.getDestinationNetwork()) + .setService(natRule.getService()) + .setScope(natRule.getScope()) + .setFirewallMatch(natRule.getFirewallMatch()) + .setPolicyBasedVpnMode(natRule.getPolicyBasedVpnMode()) + .setLogging(natRule.getLogging()) + .setEnabled(natRule.getEnabled()) + .setTags(tags) + .setSequenceNumber(sequenceNumber) + .build(); + } + + private boolean isCatchAllSourceNatRule(PolicyNatRule natRule) { + return NatAction.SNAT.name().equals(natRule.getAction()) + && isAnyNatMatch(natRule.getSourceNetwork()) + && isAnyNatMatch(natRule.getDestinationNetwork()); + } + + private boolean isAnyNatMatch(String network) { + return network == null || network.isBlank() || "ANY".equalsIgnoreCase(network) + || "0.0.0.0/0".equals(network) || "::/0".equals(network); + } + public void createDhcpRelayConfig(String dhcpRelayConfigName, List addresses) { try { DhcpRelayConfigs service = (DhcpRelayConfigs) nsxService.apply(DhcpRelayConfigs.class); @@ -373,6 +569,7 @@ public void deleteTier1Gateway(String tier1Id) { logger.warn("The Tier 1 Gateway {} does not exist, cannot be removed", tier1Id); return; } + removeTier1VpnResources(tier1Id); removeTier1GatewayNatRules(tier1Id); localeService.delete(tier1Id, TIER_1_LOCALE_SERVICE_ID); Tier1s tier1service = (Tier1s) nsxService.apply(Tier1s.class); @@ -1236,4 +1433,765 @@ private String getGroupPath(String segmentName) { return matchingGroup.map(Group::getPath).orElse(null); } + + public void createVpnService(String tier1GatewayName, String localEndpointIp) { + String vpnServiceName = getVpnServiceName(tier1GatewayName); + String localEndpointName = getVpnLocalEndpointName(vpnServiceName); + try { + ensureTier1IpsecLocalEndpointAdvertisement(tier1GatewayName); + IpsecVpnServices vpnServices = (IpsecVpnServices) nsxService.apply(IpsecVpnServices.class); + IPSecVpnService vpnService = new IPSecVpnService.Builder() + .setId(vpnServiceName) + .setDisplayName(vpnServiceName) + .setEnabled(true) + .build(); + vpnServices.patch(tier1GatewayName, vpnServiceName, vpnService); + + LocalEndpoints localEndpoints = (LocalEndpoints) nsxService.apply(LocalEndpoints.class); + IPSecVpnLocalEndpoint localEndpoint = new IPSecVpnLocalEndpoint.Builder() + .setId(localEndpointName) + .setDisplayName(localEndpointName) + .setLocalAddress(localEndpointIp) + .setLocalId(localEndpointIp) + .build(); + localEndpoints.patch(tier1GatewayName, vpnServiceName, localEndpointName, localEndpoint); + + ensureVpnNatExemptions(tier1GatewayName, localEndpointIp); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to create NSX IPSec VPN service %s on tier-1 gateway %s, due to: %s", + vpnServiceName, tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + /** + * Returns whether the CloudStack-owned VPN service already exists. The resource uses this + * preflight result to avoid deleting a valid service when an idempotent create request fails + * after an ambiguous timeout. + */ + public boolean isVpnServicePresent(String tier1GatewayName) { + try { + IpsecVpnServices vpnServices = (IpsecVpnServices) nsxService.apply(IpsecVpnServices.class); + return vpnServices.get(tier1GatewayName, getVpnServiceName(tier1GatewayName)) != null; + } catch (NotFound e) { + return false; + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + throw new CloudRuntimeException(String.format("Failed to check NSX IPSec VPN service on tier-1 gateway %s, due to: %s", + tier1GatewayName, ae.getErrorMessage()), error); + } + } + + /** + * The local endpoint IP realizes as a Tier-1 loopback and is routable only when the Tier-1 + * advertises TIER1_IPSEC_LOCAL_ENDPOINT; gateways created by this plugin always do, but + * gateways created before that behavior are patched here + */ + private void ensureTier1IpsecLocalEndpointAdvertisement(String tier1GatewayName) { + Tier1 tier1 = getTier1Gateway(tier1GatewayName); + if (tier1 == null) { + throw new CloudRuntimeException(String.format("The Tier 1 Gateway %s does not exist", tier1GatewayName)); + } + List advertisementTypes = tier1.getRouteAdvertisementTypes(); + if (CollectionUtils.isNotEmpty(advertisementTypes) + && advertisementTypes.contains(RouteAdvertisementType.TIER1_IPSEC_LOCAL_ENDPOINT.name())) { + return; + } + List updatedTypes = new ArrayList<>(); + if (CollectionUtils.isNotEmpty(advertisementTypes)) { + updatedTypes.addAll(advertisementTypes); + } + updatedTypes.add(RouteAdvertisementType.TIER1_IPSEC_LOCAL_ENDPOINT.name()); + Tier1s tier1service = (Tier1s) nsxService.apply(Tier1s.class); + Tier1 tier1Update = new Tier1.Builder() + .setRouteAdvertisementTypes(updatedTypes) + .build(); + tier1service.patch(tier1GatewayName, tier1Update); + } + + public void deleteVpnService(String tier1GatewayName) { + if (getTier1Gateway(tier1GatewayName) == null) { + // On VPC teardown the tier-1 gateway is removed (along with its VPN objects) before the + // VPN gateway cleanup runs + logger.debug("The Tier 1 Gateway {} does not exist, skipping the removal of its VPN service", tier1GatewayName); + return; + } + removeTier1VpnResources(tier1GatewayName); + restoreSourceNatRuleSequence(tier1GatewayName); + } + + public void createRouteBasedVpnSession(String tier1GatewayName, String connectionUuid, String peerAddress, + String psk, String ikePolicy, String espPolicy, Long ikeLifetime, + Long espLifetime, boolean dpdEnabled, String ikeVersion, boolean passive, + String vtiLocalIp, int vtiPrefixLength) { + // NSX reaps deleted objects asynchronously and rejects a create under a path that is still + // marked for deletion, which happens when a connection is recreated shortly after teardown + for (int attempt = 1; attempt <= VPN_MARKED_FOR_DELETION_RETRIES; attempt++) { + try { + doCreateRouteBasedVpnSession(tier1GatewayName, connectionUuid, peerAddress, psk, ikePolicy, espPolicy, + ikeLifetime, espLifetime, dpdEnabled, ikeVersion, passive, vtiLocalIp, vtiPrefixLength); + return; + } catch (CloudRuntimeException e) { + if (attempt == VPN_MARKED_FOR_DELETION_RETRIES || !isMarkedForDeletionError(e)) { + // All VPN objects use deterministic IDs and PATCH/upsert semantics. Do not + // delete profiles here: an ambiguous timeout may have followed a successful + // patch for an existing connection, and deleting those objects would destroy + // a live tunnel. The normal connection-delete path is the authoritative cleanup. + throw e; + } + logger.info("A VPN object for connection {} is still being purged by NSX, retrying in {}s (attempt {}/{})", + connectionUuid, VPN_MARKED_FOR_DELETION_RETRY_INTERVAL_SECS, attempt, VPN_MARKED_FOR_DELETION_RETRIES); + try { + Thread.sleep(VPN_MARKED_FOR_DELETION_RETRY_INTERVAL_SECS * 1000L); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw e; + } + } + } + } + + private boolean isMarkedForDeletionError(CloudRuntimeException e) { + return e.getMessage() != null && e.getMessage().contains("marked for deletion"); + } + + private void doCreateRouteBasedVpnSession(String tier1GatewayName, String connectionUuid, String peerAddress, + String psk, String ikePolicy, String espPolicy, Long ikeLifetime, + Long espLifetime, boolean dpdEnabled, String ikeVersion, boolean passive, + String vtiLocalIp, int vtiPrefixLength) { + String vpnServiceName = getVpnServiceName(tier1GatewayName); + String localEndpointName = getVpnLocalEndpointName(vpnServiceName); + String sessionName = getVpnSessionName(connectionUuid); + String ikeProfileName = getVpnIkeProfileName(connectionUuid); + String espProfileName = getVpnEspProfileName(connectionUuid); + String dpdProfileName = getVpnDpdProfileName(connectionUuid); + try { + IpsecVpnIkeProfiles ikeProfiles = (IpsecVpnIkeProfiles) nsxService.apply(IpsecVpnIkeProfiles.class); + IpsecVpnTunnelProfiles espProfiles = (IpsecVpnTunnelProfiles) nsxService.apply(IpsecVpnTunnelProfiles.class); + IpsecVpnDpdProfiles dpdProfiles = (IpsecVpnDpdProfiles) nsxService.apply(IpsecVpnDpdProfiles.class); + Sessions sessions = (Sessions) nsxService.apply(Sessions.class); + boolean sessionExisted = isVpnSessionPresent(sessions, tier1GatewayName, vpnServiceName, sessionName); + boolean ikeProfileExisted = isVpnIkeProfilePresent(ikeProfiles, ikeProfileName); + boolean espProfileExisted = isVpnTunnelProfilePresent(espProfiles, espProfileName); + boolean dpdProfileExisted = isVpnDpdProfilePresent(dpdProfiles, dpdProfileName); + + try { + IPSecVpnIkeProfile ikeProfile = new IPSecVpnIkeProfile.Builder() + .setId(ikeProfileName) + .setDisplayName(ikeProfileName) + .setEncryptionAlgorithms(NsxVpnCryptoUtils.getEncryptionAlgorithms(ikePolicy)) + .setDigestAlgorithms(NsxVpnCryptoUtils.getDigestAlgorithms(ikePolicy)) + .setDhGroups(NsxVpnCryptoUtils.getDhGroups(ikePolicy)) + .setIkeVersion(NsxVpnCryptoUtils.getIkeVersion(ikeVersion)) + .setSaLifeTime(ikeLifetime) + .build(); + ikeProfiles.patch(ikeProfileName, ikeProfile); + + List espDhGroups = NsxVpnCryptoUtils.getDhGroups(espPolicy); + IPSecVpnTunnelProfile.Builder espProfileBuilder = new IPSecVpnTunnelProfile.Builder() + .setId(espProfileName) + .setDisplayName(espProfileName) + .setEncryptionAlgorithms(NsxVpnCryptoUtils.getEncryptionAlgorithms(espPolicy)) + .setDigestAlgorithms(NsxVpnCryptoUtils.getDigestAlgorithms(espPolicy)) + .setEnablePerfectForwardSecrecy(!espDhGroups.isEmpty()) + .setSaLifeTime(espLifetime); + if (!espDhGroups.isEmpty()) { + espProfileBuilder.setDhGroups(espDhGroups); + } + espProfiles.patch(espProfileName, espProfileBuilder.build()); + + // On demand probing only checks the peer when there is traffic to send and nothing has been + // heard back, so an idle tunnel is not torn down for want of a probe response the way the + // periodic default does; CloudStack only exposes DPD as a flag, hence the fixed timers + IPSecVpnDpdProfile dpdProfile = new IPSecVpnDpdProfile.Builder() + .setId(dpdProfileName) + .setDisplayName(dpdProfileName) + .setEnabled(dpdEnabled) + .setDpdProbeMode(IPSecVpnDpdProfile.DPD_PROBE_MODE_ON_DEMAND) + .setDpdProbeInterval(VPN_DPD_PROBE_INTERVAL_SECS) + .setRetryCount(VPN_DPD_RETRY_COUNT) + .build(); + dpdProfiles.patch(dpdProfileName, dpdProfile); + + IPSecVpnTunnelInterface tunnelInterface = new IPSecVpnTunnelInterface.Builder() + .setId(VPN_DEFAULT_TUNNEL_INTERFACE_NAME) + .setDisplayName(VPN_DEFAULT_TUNNEL_INTERFACE_NAME) + .setIpSubnets(List.of(new TunnelInterfaceIPSubnet.Builder() + .setIpAddresses(List.of(vtiLocalIp)) + .setPrefixLength((long) vtiPrefixLength) + .build())) + .build(); + RouteBasedIPSecVpnSession session = new RouteBasedIPSecVpnSession.Builder() + .setId(sessionName) + .setDisplayName(sessionName) + .setEnabled(true) + .setAuthenticationMode(IPSecVpnSession.AUTHENTICATION_MODE_PSK) + .setPsk(psk) + .setPeerAddress(peerAddress) + .setPeerId(peerAddress) + .setConnectionInitiationMode(passive ? IPSecVpnSession.CONNECTION_INITIATION_MODE_RESPOND_ONLY + : IPSecVpnSession.CONNECTION_INITIATION_MODE_INITIATOR) + .setIkeProfilePath(IPSEC_VPN_IKE_PROFILES_PATH_PREFIX + ikeProfileName) + .setTunnelProfilePath(IPSEC_VPN_TUNNEL_PROFILES_PATH_PREFIX + espProfileName) + .setDpdProfilePath(IPSEC_VPN_DPD_PROFILES_PATH_PREFIX + dpdProfileName) + .setLocalEndpointPath(getVpnLocalEndpointPath(tier1GatewayName, vpnServiceName, localEndpointName)) + .setTunnelInterfaces(List.of(tunnelInterface)) + .build(); + sessions.patch(tier1GatewayName, vpnServiceName, sessionName, session); + } catch (RuntimeException e) { + if (!sessionExisted) { + boolean sessionRemoved = deleteVpnSessionAfterCreateFailure(sessions, tier1GatewayName, + vpnServiceName, sessionName); + if (sessionRemoved) { + deleteNewVpnProfilesAfterCreateFailure(ikeProfiles, espProfiles, dpdProfiles, + connectionUuid, ikeProfileExisted, espProfileExisted, dpdProfileExisted); + } + } + throw e; + } + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to create NSX IPSec VPN session %s on tier-1 gateway %s, due to: %s", + sessionName, tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + private boolean isVpnSessionPresent(Sessions sessions, String tier1GatewayName, String vpnServiceName, + String sessionName) { + try { + return sessions.get(tier1GatewayName, vpnServiceName, sessionName) != null; + } catch (NotFound e) { + return false; + } + } + + private boolean isVpnIkeProfilePresent(IpsecVpnIkeProfiles profiles, String profileName) { + try { + return profiles.get(profileName) != null; + } catch (NotFound e) { + return false; + } + } + + private boolean isVpnTunnelProfilePresent(IpsecVpnTunnelProfiles profiles, String profileName) { + try { + return profiles.get(profileName) != null; + } catch (NotFound e) { + return false; + } + } + + private boolean isVpnDpdProfilePresent(IpsecVpnDpdProfiles profiles, String profileName) { + try { + return profiles.get(profileName) != null; + } catch (NotFound e) { + return false; + } + } + + private boolean deleteVpnSessionAfterCreateFailure(Sessions sessions, String tier1GatewayName, + String vpnServiceName, String sessionName) { + try { + sessions.delete(tier1GatewayName, vpnServiceName, sessionName); + return true; + } catch (NotFound e) { + logger.debug("The partially created VPN session {} on tier-1 gateway {} was not present during cleanup", + sessionName, tier1GatewayName); + return true; + } catch (Error e) { + logger.warn("Failed to remove the partially created VPN session {} on tier-1 gateway {} after creation failed: {}", + sessionName, tier1GatewayName, e.getMessage()); + return false; + } catch (RuntimeException e) { + logger.warn("Failed to remove the partially created VPN session {} on tier-1 gateway {} after creation failed: {}", + sessionName, tier1GatewayName, e.getMessage()); + return false; + } + } + + private void deleteNewVpnProfilesAfterCreateFailure(IpsecVpnIkeProfiles ikeProfiles, + IpsecVpnTunnelProfiles espProfiles, + IpsecVpnDpdProfiles dpdProfiles, + String connectionUuid, + boolean ikeProfileExisted, + boolean espProfileExisted, + boolean dpdProfileExisted) { + if (!ikeProfileExisted) { + deleteVpnProfileAfterCreateFailure(() -> ikeProfiles.delete(getVpnIkeProfileName(connectionUuid)), + "IKE", connectionUuid); + } + if (!espProfileExisted) { + deleteVpnProfileAfterCreateFailure(() -> espProfiles.delete(getVpnEspProfileName(connectionUuid)), + "tunnel", connectionUuid); + } + if (!dpdProfileExisted) { + deleteVpnProfileAfterCreateFailure(() -> dpdProfiles.delete(getVpnDpdProfileName(connectionUuid)), + "DPD", connectionUuid); + } + } + + private void deleteVpnProfileAfterCreateFailure(Runnable deleteAction, String profileType, + String connectionUuid) { + try { + deleteAction.run(); + } catch (NotFound e) { + logger.debug("The partially created {} profile of VPN connection {} was absent during cleanup", + profileType, connectionUuid); + } catch (RuntimeException e) { + logger.warn("Failed to remove the partially created {} profile of VPN connection {}: {}", + profileType, connectionUuid, e.getMessage()); + } + } + + public void addVpnConnectionRoutes(String tier1GatewayName, String connectionUuid, List peerCidrs, + String vtiPeerIp, String vpcCidr) { + try { + deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid)); + deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid)); + com.vmware.nsx_policy.infra.tier_1s.StaticRoutes staticRoutesService = + (com.vmware.nsx_policy.infra.tier_1s.StaticRoutes) nsxService.apply(com.vmware.nsx_policy.infra.tier_1s.StaticRoutes.class); + NatRules natService = (NatRules) nsxService.apply(NatRules.class); + for (int i = 0; i < peerCidrs.size(); i++) { + String peerCidr = peerCidrs.get(i); + String routeName = getVpnStaticRouteName(connectionUuid, i); + com.vmware.nsx_policy.model.StaticRoutes staticRoute = new com.vmware.nsx_policy.model.StaticRoutes.Builder() + .setId(routeName) + .setDisplayName(routeName) + .setNetwork(peerCidr) + .setNextHops(List.of(new RouterNexthop.Builder().setIpAddress(vtiPeerIp).build())) + .build(); + staticRoutesService.patch(tier1GatewayName, routeName, staticRoute); + + // Route-based VPN does not bypass NAT: without a NO_SNAT rule the tier-1 match-any + // SNAT would rewrite VPC-to-remote traffic before it enters the tunnel + String noSnatRuleName = getVpnNoSnatRuleName(connectionUuid, i); + PolicyNatRule noSnatRule = new PolicyNatRule.Builder() + .setId(noSnatRuleName) + .setDisplayName(noSnatRuleName) + .setAction(NatAction.NO_SNAT.name()) + .setSourceNetwork(vpcCidr) + .setDestinationNetwork(peerCidr) + .setSequenceNumber(VPN_NO_SNAT_SEQUENCE_NUMBER) + .setEnabled(true) + .build(); + natService.patch(tier1GatewayName, NatId.USER.name(), noSnatRuleName, noSnatRule); + } + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to add the routes for NSX IPSec VPN connection %s on tier-1 gateway %s, due to: %s", + connectionUuid, tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + public void deleteVpnConnection(String tier1GatewayName, String connectionUuid) { + RuntimeException failure = null; + // Delete by prefix instead of recomputing names from the current peer CIDR list: the + // customer gateway's CIDRs may have changed since the routes and NO_SNAT rules were created + failure = runVpnCleanupStep(failure, "static routes", connectionUuid, + () -> deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid))); + failure = runVpnCleanupStep(failure, "NO_SNAT rules", connectionUuid, + () -> deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid))); + failure = runVpnCleanupStep(failure, "session", connectionUuid, + () -> deleteVpnSession(tier1GatewayName, connectionUuid)); + failure = runVpnCleanupStep(failure, "profiles", connectionUuid, + () -> deleteVpnSessionProfiles(connectionUuid)); + throwVpnCleanupFailure(failure, connectionUuid); + } + + private void deleteVpnSession(String tier1GatewayName, String connectionUuid) { + String vpnServiceName = getVpnServiceName(tier1GatewayName); + String sessionName = getVpnSessionName(connectionUuid); + try { + Sessions sessions = (Sessions) nsxService.apply(Sessions.class); + sessions.delete(tier1GatewayName, vpnServiceName, sessionName); + } catch (NotFound e) { + logger.debug("The VPN session {} on tier-1 gateway {} no longer exists, skipping deletion", + sessionName, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to delete the NSX IPSec VPN session %s on tier-1 gateway %s, due to: %s", + sessionName, tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + public void updateVpnConnectionState(String tier1GatewayName, String connectionUuid, boolean enabled) { + String vpnServiceName = getVpnServiceName(tier1GatewayName); + String sessionName = getVpnSessionName(connectionUuid); + try { + Sessions sessions = (Sessions) nsxService.apply(Sessions.class); + RouteBasedIPSecVpnSession update = new RouteBasedIPSecVpnSession.Builder() + .setId(sessionName) + .setEnabled(enabled) + .build(); + sessions.patch(tier1GatewayName, vpnServiceName, sessionName, update); + if (!enabled) { + deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid)); + deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid)); + } + } catch (NotFound e) { + logger.debug("The VPN session {} no longer exists on tier-1 gateway {}, skipping state update", + sessionName, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + throw new CloudRuntimeException(String.format( + "Failed to update the state of NSX IPSec VPN session %s on tier-1 gateway %s, due to: %s", + sessionName, tier1GatewayName, ae.getErrorMessage()), error); + } + } + + /** + * Removes every object created for a connection when route or NAT programming fails after the + * session itself was created. This is also used by the permanent connection-delete path. + */ + public void rollbackVpnConnection(String tier1GatewayName, String connectionUuid) { + deleteVpnConnection(tier1GatewayName, connectionUuid); + } + + private void deleteVpnStaticRoutesByPrefix(String tier1GatewayName, String routeNamePrefix) { + com.vmware.nsx_policy.infra.tier_1s.StaticRoutes staticRoutesService = + (com.vmware.nsx_policy.infra.tier_1s.StaticRoutes) nsxService.apply(com.vmware.nsx_policy.infra.tier_1s.StaticRoutes.class); + try { + List staticRoutes = + PagedFetcher.withPageFetcher( + cursor -> staticRoutesService.list(tier1GatewayName, cursor, false, null, null, null, null) + ).cursorExtractor(StaticRoutesListResult::getCursor) + .itemsExtractor(StaticRoutesListResult::getResults) + .itemsSetter((page, allItems) -> { + page.setResults(allItems); + page.setResultCount((long) allItems.size()); + }) + .fetchAll() + .getResults(); + if (CollectionUtils.isEmpty(staticRoutes)) { + return; + } + for (com.vmware.nsx_policy.model.StaticRoutes staticRoute : staticRoutes) { + if (staticRoute.getId() != null && staticRoute.getId().startsWith(routeNamePrefix)) { + logger.debug("Removing the VPN static route {} from tier-1 gateway {}", staticRoute.getId(), tier1GatewayName); + staticRoutesService.delete(tier1GatewayName, staticRoute.getId()); + } + } + } catch (NotFound e) { + logger.debug("No static routes matching the prefix {} are left on tier-1 gateway {}, skipping deletion", + routeNamePrefix, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to delete the VPN static routes matching the prefix %s on tier-1 gateway %s, due to: %s", + routeNamePrefix, tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + private void deleteVpnNoSnatRulesByPrefix(String tier1GatewayName, String ruleNamePrefix) { + NatRules natService = (NatRules) nsxService.apply(NatRules.class); + try { + List natRules = PagedFetcher.withPageFetcher( + cursor -> natService.list(tier1GatewayName, NatId.USER.name(), cursor, false, null, null, null, null) + ).cursorExtractor(PolicyNatRuleListResult::getCursor) + .itemsExtractor(PolicyNatRuleListResult::getResults) + .itemsSetter((page, allItems) -> { + page.setResults(allItems); + page.setResultCount((long) allItems.size()); + }) + .fetchAll() + .getResults(); + if (CollectionUtils.isEmpty(natRules)) { + return; + } + for (PolicyNatRule natRule : natRules) { + if (natRule.getId() != null && natRule.getId().startsWith(ruleNamePrefix)) { + logger.debug("Removing the VPN NO_SNAT rule {} from tier-1 gateway {}", natRule.getId(), tier1GatewayName); + natService.delete(tier1GatewayName, NatId.USER.name(), natRule.getId()); + } + } + } catch (NotFound e) { + logger.debug("No NO_SNAT rules matching the prefix {} are left on tier-1 gateway {}, skipping deletion", + ruleNamePrefix, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to delete the VPN NO_SNAT rules matching the prefix %s on tier-1 gateway %s, due to: %s", + ruleNamePrefix, tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + private void deleteVpnSessionProfiles(String connectionUuid) { + RuntimeException failure = null; + failure = runVpnCleanupStep(failure, "IKE profile", connectionUuid, + () -> deleteVpnIkeProfile(connectionUuid)); + failure = runVpnCleanupStep(failure, "tunnel profile", connectionUuid, + () -> deleteVpnTunnelProfile(connectionUuid)); + failure = runVpnCleanupStep(failure, "DPD profile", connectionUuid, + () -> deleteVpnDpdProfile(connectionUuid)); + throwVpnCleanupFailure(failure, connectionUuid); + } + + private void deleteVpnIkeProfile(String connectionUuid) { + try { + IpsecVpnIkeProfiles ikeProfiles = (IpsecVpnIkeProfiles) nsxService.apply(IpsecVpnIkeProfiles.class); + ikeProfiles.delete(getVpnIkeProfileName(connectionUuid)); + } catch (NotFound e) { + logger.debug("The IKE profile of VPN connection {} no longer exists, skipping deletion", connectionUuid); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to delete the IKE profile of VPN connection %s, due to: %s", + connectionUuid, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + private void deleteVpnTunnelProfile(String connectionUuid) { + try { + IpsecVpnTunnelProfiles espProfiles = (IpsecVpnTunnelProfiles) nsxService.apply(IpsecVpnTunnelProfiles.class); + espProfiles.delete(getVpnEspProfileName(connectionUuid)); + } catch (NotFound e) { + logger.debug("The tunnel profile of VPN connection {} no longer exists, skipping deletion", connectionUuid); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to delete the tunnel profile of VPN connection %s, due to: %s", + connectionUuid, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + private void deleteVpnDpdProfile(String connectionUuid) { + try { + IpsecVpnDpdProfiles dpdProfiles = (IpsecVpnDpdProfiles) nsxService.apply(IpsecVpnDpdProfiles.class); + dpdProfiles.delete(getVpnDpdProfileName(connectionUuid)); + } catch (NotFound e) { + logger.debug("The DPD profile of VPN connection {} no longer exists, skipping deletion", connectionUuid); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to delete the DPD profile of VPN connection %s, due to: %s", + connectionUuid, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + private RuntimeException runVpnCleanupStep(RuntimeException failure, String resource, String connectionUuid, + Runnable cleanup) { + try { + cleanup.run(); + } catch (RuntimeException e) { + if (failure == null) { + return e; + } + failure.addSuppressed(e); + logger.warn("Failed to remove NSX VPN {} for connection {} after an earlier cleanup failure: {}", + resource, connectionUuid, e.getMessage()); + } + return failure; + } + + private void throwVpnCleanupFailure(RuntimeException failure, String connectionUuid) { + if (failure == null) { + return; + } + if (failure instanceof CloudRuntimeException) { + throw (CloudRuntimeException) failure; + } + throw new CloudRuntimeException(String.format( + "Failed to remove all NSX VPN resources for connection %s: %s", connectionUuid, failure.getMessage()), failure); + } + + public String getVpnSessionStatus(String tier1GatewayName, String connectionUuid) { + String vpnServiceName = getVpnServiceName(tier1GatewayName); + String sessionName = getVpnSessionName(connectionUuid); + try { + DetailedStatus detailedStatusService = (DetailedStatus) nsxService.apply(DetailedStatus.class); + AggregateIPSecVpnSessionStatus aggregateStatus = detailedStatusService.get(tier1GatewayName, vpnServiceName, sessionName, null, null); + List results = aggregateStatus == null ? null : aggregateStatus.getResults(); + if (CollectionUtils.isEmpty(results)) { + return VPN_SESSION_STATUS_UNKNOWN; + } + List statuses = results.stream() + .map(result -> result._convertTo(IPSecVpnSessionStatusNsxt.class).getRuntimeStatus()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + if (statuses.isEmpty()) { + return VPN_SESSION_STATUS_UNKNOWN; + } + if (statuses.contains(IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_DOWN)) { + return IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_DOWN; + } + if (statuses.stream().allMatch(IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_UP::equals)) { + return IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_UP; + } + return statuses.get(0); + } catch (NotFound e) { + logger.debug("The VPN session {} no longer exists on tier-1 gateway {}", sessionName, tier1GatewayName); + return VPN_SESSION_STATUS_NOT_FOUND; + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to get the status of NSX IPSec VPN session %s on tier-1 gateway %s, due to: %s", + sessionName, tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + /** + * VPN sessions, local endpoints, services and static routes must be removed before the tier-1 + * locale-services deletion during gateway teardown + */ + private void removeTier1VpnResources(String tier1Id) { + deleteVpnStaticRoutesByPrefix(tier1Id, getVpnSessionName("")); + deleteVpnNoSnatRulesByPrefix(tier1Id, getVpnSessionName("")); + deleteVpnLocalEndpointNoSnatRule(tier1Id); + try { + IpsecVpnServices vpnServices = (IpsecVpnServices) nsxService.apply(IpsecVpnServices.class); + List services = new ArrayList<>(PagedFetcher.withPageFetcher( + cursor -> vpnServices.list(tier1Id, cursor, false, null, null, false, null)) + .cursorExtractor(IPSecVpnServiceListResult::getCursor) + .itemsExtractor(IPSecVpnServiceListResult::getResults) + .itemsSetter((page, allItems) -> { + page.setResults(allItems); + page.setResultCount((long) allItems.size()); + }) + .fetchAll().getResults()); + // A Tier-1 may also carry VPN services owned by an operator or another integration. + // CloudStack owns exactly the deterministic service created for this gateway. + String cloudStackVpnServiceName = getVpnServiceName(tier1Id); + services.removeIf(service -> !cloudStackVpnServiceName.equals(service.getId())); + if (CollectionUtils.isEmpty(services)) { + return; + } + Sessions sessions = (Sessions) nsxService.apply(Sessions.class); + LocalEndpoints localEndpoints = (LocalEndpoints) nsxService.apply(LocalEndpoints.class); + for (IPSecVpnService service : services) { + List sessionResults = PagedFetcher.withPageFetcher( + cursor -> sessions.list(tier1Id, service.getId(), cursor, false, null, null, false, null)) + .cursorExtractor(IPSecVpnSessionListResult::getCursor) + .itemsExtractor(IPSecVpnSessionListResult::getResults) + .itemsSetter((page, allItems) -> { + page.setResults(allItems); + page.setResultCount((long) allItems.size()); + }) + .fetchAll().getResults(); + if (CollectionUtils.isNotEmpty(sessionResults)) { + String sessionNamePrefix = getVpnSessionName(""); + for (Structure result : sessionResults) { + IPSecVpnSession session = result._convertTo(IPSecVpnSession.class); + logger.debug("Removing VPN session {} from the VPN service {} of Tier 1 Gateway {}", session.getId(), service.getId(), tier1Id); + sessions.delete(tier1Id, service.getId(), session.getId()); + if (session.getId().startsWith(sessionNamePrefix)) { + deleteVpnSessionProfiles(session.getId().substring(sessionNamePrefix.length())); + } + } + } + List localEndpointResults = PagedFetcher.withPageFetcher( + cursor -> localEndpoints.list(tier1Id, service.getId(), cursor, false, null, null, false, null)) + .cursorExtractor(IPSecVpnLocalEndpointListResult::getCursor) + .itemsExtractor(IPSecVpnLocalEndpointListResult::getResults) + .itemsSetter((page, allItems) -> { + page.setResults(allItems); + page.setResultCount((long) allItems.size()); + }) + .fetchAll().getResults(); + if (CollectionUtils.isNotEmpty(localEndpointResults)) { + for (IPSecVpnLocalEndpoint localEndpoint : localEndpointResults) { + logger.debug("Removing VPN local endpoint {} from the VPN service {} of Tier 1 Gateway {}", localEndpoint.getId(), service.getId(), tier1Id); + localEndpoints.delete(tier1Id, service.getId(), localEndpoint.getId()); + } + } + logger.debug("Removing VPN service {} from Tier 1 Gateway {}", service.getId(), tier1Id); + vpnServices.delete(tier1Id, service.getId()); + } + } catch (NotFound e) { + logger.debug("The VPN resources of the Tier 1 Gateway {} no longer exist, skipping deletion", tier1Id); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to remove the VPN resources of the Tier 1 Gateway %s, due to: %s", + tier1Id, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + private void deleteVpnLocalEndpointNoSnatRule(String tier1GatewayName) { + String ruleName = getVpnLocalEndpointNoSnatRuleName(getVpnServiceName(tier1GatewayName)); + try { + NatRules natService = (NatRules) nsxService.apply(NatRules.class); + natService.delete(tier1GatewayName, NatId.USER.name(), ruleName); + } catch (NotFound e) { + logger.debug("The VPN local-endpoint no-SNAT rule {} no longer exists on tier-1 gateway {}", + ruleName, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + throw new CloudRuntimeException(String.format( + "Failed to delete the VPN local-endpoint no-SNAT rule %s on tier-1 gateway %s, due to: %s", + ruleName, tier1GatewayName, ae.getErrorMessage()), error); + } + } + + /** + * Lists the local VTI addresses of the route-based VPN sessions on a tier-1 gateway, excluding + * the session of the given connection; used to fail closed on deterministic VTI collisions. + */ + public Set getRouteBasedVpnSessionLocalVtiIps(String tier1GatewayName, String excludedConnectionUuid) { + String vpnServiceName = getVpnServiceName(tier1GatewayName); + String excludedSessionName = getVpnSessionName(excludedConnectionUuid); + Set vtiIps = new HashSet<>(); + try { + Sessions sessions = (Sessions) nsxService.apply(Sessions.class); + List sessionResults = PagedFetcher.withPageFetcher( + cursor -> sessions.list(tier1GatewayName, vpnServiceName, cursor, false, null, null, false, null)) + .cursorExtractor(IPSecVpnSessionListResult::getCursor) + .itemsExtractor(IPSecVpnSessionListResult::getResults) + .itemsSetter((page, allItems) -> { + page.setResults(allItems); + page.setResultCount((long) allItems.size()); + }) + .fetchAll().getResults(); + for (Structure result : sessionResults) { + IPSecVpnSession session = result._convertTo(IPSecVpnSession.class); + if (excludedSessionName.equals(session.getId()) + || !RouteBasedIPSecVpnSession.class.getSimpleName().equals(session.getResourceType())) { + continue; + } + RouteBasedIPSecVpnSession routeBasedSession = result._convertTo(RouteBasedIPSecVpnSession.class); + if (CollectionUtils.isEmpty(routeBasedSession.getTunnelInterfaces())) { + continue; + } + for (IPSecVpnTunnelInterface tunnelInterface : routeBasedSession.getTunnelInterfaces()) { + if (CollectionUtils.isEmpty(tunnelInterface.getIpSubnets())) { + continue; + } + for (TunnelInterfaceIPSubnet ipSubnet : tunnelInterface.getIpSubnets()) { + if (CollectionUtils.isNotEmpty(ipSubnet.getIpAddresses())) { + vtiIps.addAll(ipSubnet.getIpAddresses()); + } + } + } + } + } catch (NotFound e) { + logger.debug("The VPN service {} does not exist yet on tier-1 gateway {}, no VTI addresses are in use", + vpnServiceName, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to list the VPN sessions on tier-1 gateway %s, due to: %s", + tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + return vtiIps; + } + + private String getVpnLocalEndpointPath(String tier1GatewayName, String vpnServiceName, String localEndpointName) { + return TIER_1_GATEWAY_PATH_PREFIX + tier1GatewayName + "/ipsec-vpn-services/" + vpnServiceName + + "/local-endpoints/" + localEndpointName; + } } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java index 0486de96dd1b..a5bdb659682d 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java @@ -40,22 +40,32 @@ import com.cloud.host.HostVO; import com.cloud.host.Status; import com.cloud.network.IpAddress; +import com.cloud.network.IpAddressManager; import com.cloud.network.Network; import com.cloud.network.NetworkModel; import com.cloud.network.Networks; import com.cloud.network.PhysicalNetworkServiceProvider; import com.cloud.network.PublicIpAddress; import com.cloud.network.SDNProviderNetworkRule; +import com.cloud.network.Site2SiteCustomerGateway; +import com.cloud.network.Site2SiteVpnConnection; +import com.cloud.network.Site2SiteVpnGateway; import com.cloud.network.VirtualRouterProvider; +import com.cloud.network.dao.FirewallRulesDao; import com.cloud.network.dao.IPAddressDao; import com.cloud.network.dao.IPAddressVO; import com.cloud.network.dao.LoadBalancerVMMapDao; +import com.cloud.network.dao.LoadBalancerDao; import com.cloud.network.dao.LoadBalancerVMMapVO; import com.cloud.network.dao.NetworkDao; import com.cloud.network.dao.NetworkVO; import com.cloud.network.dao.PhysicalNetworkDao; import com.cloud.network.dao.PhysicalNetworkServiceProviderDao; import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.dao.Site2SiteCustomerGatewayDao; +import com.cloud.network.dao.Site2SiteCustomerGatewayVO; +import com.cloud.network.dao.Site2SiteVpnGatewayDao; +import com.cloud.network.dao.Site2SiteVpnGatewayVO; import com.cloud.network.dao.VirtualRouterProviderDao; import com.cloud.network.element.DhcpServiceProvider; import com.cloud.network.element.DnsServiceProvider; @@ -64,19 +74,24 @@ import com.cloud.network.element.LoadBalancingServiceProvider; import com.cloud.network.element.NetworkACLServiceProvider; import com.cloud.network.element.PortForwardingServiceProvider; +import com.cloud.network.element.Site2SiteVpnServiceProvider; import com.cloud.network.element.StaticNatServiceProvider; import com.cloud.network.element.VirtualRouterElement; import com.cloud.network.element.VirtualRouterProviderVO; import com.cloud.network.element.VpcProvider; import com.cloud.network.lb.LoadBalancingRule; +import com.cloud.network.nsx.NsxVpnGatewayResult; import com.cloud.network.rules.FirewallRule; import com.cloud.network.rules.LoadBalancerContainer; import com.cloud.network.rules.PortForwardingRule; import com.cloud.network.rules.StaticNat; +import com.cloud.network.rules.dao.PortForwardingRulesDao; import com.cloud.network.vpc.NetworkACLItem; import com.cloud.network.vpc.PrivateGateway; import com.cloud.network.vpc.StaticRouteProfile; import com.cloud.network.vpc.Vpc; +import com.cloud.network.vpc.VpcService; +import com.cloud.network.vpc.VpcManager; import com.cloud.network.vpc.dao.VpcOfferingServiceMapDao; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; @@ -108,14 +123,20 @@ import org.apache.cloudstack.api.command.admin.internallb.ConfigureInternalLoadBalancerElementCmd; import org.apache.cloudstack.api.command.admin.internallb.CreateInternalLoadBalancerElementCmd; import org.apache.cloudstack.api.command.admin.internallb.ListInternalLoadBalancerElementsCmd; +import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.network.element.InternalLoadBalancerElementService; import org.apache.cloudstack.resource.NsxLoadBalancerMember; import org.apache.cloudstack.resource.NsxNetworkRule; import com.cloud.network.SDNProviderOpObject; +import org.apache.cloudstack.utils.NsxHelper; +import org.apache.cloudstack.utils.NsxVpnCryptoUtils; +import org.apache.commons.lang3.BooleanUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.cloudstack.resourcedetail.FirewallRuleDetailVO; +import org.apache.cloudstack.resourcedetail.UserIpAddressDetailVO; import org.apache.cloudstack.resourcedetail.dao.FirewallRuleDetailsDao; +import org.apache.cloudstack.resourcedetail.dao.UserIpAddressDetailsDao; import org.springframework.stereotype.Component; import javax.inject.Inject; @@ -135,8 +156,10 @@ @Component public class NsxElement extends AdapterBase implements DhcpServiceProvider, DnsServiceProvider, VpcProvider, StaticNatServiceProvider, IpDeployer, PortForwardingServiceProvider, NetworkACLServiceProvider, - LoadBalancingServiceProvider, FirewallServiceProvider, InternalLoadBalancerElementService, ResourceStateAdapter, Listener { + LoadBalancingServiceProvider, FirewallServiceProvider, Site2SiteVpnServiceProvider, + InternalLoadBalancerElementService, ResourceStateAdapter, Listener { + protected static final String NSX_VPN_GATEWAY_IP_DETAIL = "nsxVpnGatewayIp"; @Inject AccountManager accountMgr; @@ -167,11 +190,29 @@ public class NsxElement extends AdapterBase implements DhcpServiceProvider, Dns @Inject LoadBalancerVMMapDao lbVmMapDao; @Inject + LoadBalancerDao loadBalancerDao; + @Inject VirtualRouterProviderDao vrProviderDao; @Inject PhysicalNetworkServiceProviderDao pNtwkSvcProviderDao; @Inject FirewallRuleDetailsDao firewallRuleDetailsDao; + @Inject + IpAddressManager ipAddressManager; + @Inject + VpcService vpcService; + @Inject + VpcManager vpcManager; + @Inject + Site2SiteVpnGatewayDao vpnGatewayDao; + @Inject + Site2SiteCustomerGatewayDao customerGatewayDao; + @Inject + UserIpAddressDetailsDao userIpAddressDetailsDao; + @Inject + FirewallRulesDao firewallRulesDao; + @Inject + PortForwardingRulesDao portForwardingRulesDao; protected Logger logger = LogManager.getLogger(getClass()); @@ -214,6 +255,11 @@ private static Map> initCapabil sourceNatCapabilities.put(Network.Capability.RedundantRouter, "true"); sourceNatCapabilities.put(Network.Capability.SupportedSourceNatTypes, "peraccount"); capabilities.put(Network.Service.SourceNat, sourceNatCapabilities); + + Map vpnCapabilities = new HashMap<>(); + vpnCapabilities.put(Network.Capability.SupportedVpnProtocols, "ipsec"); + vpnCapabilities.put(Network.Capability.VpnTypes, "s2svpn"); + capabilities.put(Network.Service.Vpn, vpnCapabilities); return capabilities; } @Override @@ -941,4 +987,283 @@ public List> getCommands() { public boolean updateVpcSourceNatIp(Vpc vpc, IpAddress address) { return nsxService.updateVpcSourceNatIp(vpc, address); } + + protected boolean isVpnProvidedByNsx(Vpc vpc) { + if (Objects.isNull(vpc)) { + return false; + } + if (vpcManager != null) { + return vpcManager.isProviderSupportServiceInVpc(vpc.getId(), Network.Service.Vpn, Network.Provider.Nsx); + } + return Objects.nonNull(vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId( + Network.Service.Vpn.getName(), Network.Provider.Nsx.getName(), vpc.getVpcOfferingId())); + } + + protected boolean isVpnProvidedByNsx(Vpc vpc, Site2SiteVpnGateway gateway) { + return vpc != null && (isVpnProvidedByNsx(vpc) || ownsVpnGateway(gateway)); + } + + @Override + public IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { + if (!isVpnProvidedByNsx(vpc)) { + return null; + } + IPAddressVO ip; + boolean autoAcquired = false; + if (Objects.nonNull(requestedIp)) { + ip = validateRequestedVpnGatewayIp(vpc, requestedIp); + } else { + ip = allocateVpnGatewayIp(vpc); + autoAcquired = true; + } + if (!autoAcquired) { + try { + // Mark ownership first so ambiguous NSX responses remain recoverable. + userIpAddressDetailsDao.addDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL, "false", false); + } catch (Exception e) { + throw new CloudRuntimeException(String.format( + "Failed to record NSX VPN ownership for requested IP %s of VPC %s", + ip.getAddress(), vpc.getName()), e); + } + } + boolean endpointMayBeInUse = true; + try { + NsxVpnGatewayResult result = nsxService.createVpnGateway(vpc, ip.getAddress().addr()); + endpointMayBeInUse = result.isEndpointMayBeInUse(); + if (!result.isSuccessful()) { + throw new CloudRuntimeException(String.format("The NSX VPN gateway service for VPC %s was not created: the provider returned an unsuccessful answer", + vpc.getName())); + } + } catch (Exception e) { + if (autoAcquired && !endpointMayBeInUse) { + try { + releaseAutoAcquiredVpnGatewayIp(ip); + } catch (Exception cleanupException) { + logger.warn("Failed to release the auto-acquired VPN gateway IP {} of VPC {} after creation failed: {}", + ip.getAddress(), vpc.getName(), cleanupException.getMessage()); + } + } else if (autoAcquired) { + logger.warn("Retaining auto-acquired VPN gateway IP {} for VPC {} because the NSX endpoint may still be using it", + ip.getAddress(), vpc.getName()); + } else if (!endpointMayBeInUse) { + try { + userIpAddressDetailsDao.removeDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL); + } catch (Exception cleanupException) { + logger.warn("Failed to remove the NSX VPN ownership marker from requested IP {} of VPC {} after gateway creation failed: {}", + ip.getAddress(), vpc.getName(), cleanupException.getMessage()); + } + } + throw new CloudRuntimeException(String.format("Failed to create the NSX VPN gateway for VPC %s: %s", + vpc.getName(), e.getMessage()), e); + } + return ip; + } + + private IPAddressVO validateRequestedVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { + IPAddressVO ip = ipAddressDao.findById(requestedIp.getId()); + if (Objects.isNull(ip) || !Objects.equals(ip.getVpcId(), vpc.getId()) + || !ip.readyToUse() || ip.getRemoved() != null || ip.getAddress() == null) { + throw new InvalidParameterValueException(String.format( + "The requested IP id %s is not an allocated, active IP associated to the VPC %s", + requestedIp.getId(), vpc.getName())); + } + if (ip.isSourceNat() || ip.isForSystemVms()) { + throw new InvalidParameterValueException(String.format( + "The requested IP %s cannot be used as the VPN gateway IP as it is a source NAT or system IP", ip.getAddress().addr())); + } + if (ip.isOneToOneNat() || !firewallRulesDao.listByIpAndNotRevoked(ip.getId()).isEmpty() + || !portForwardingRulesDao.listByIpAndNotRevoked(ip.getId()).isEmpty() + || !loadBalancerDao.listByIpAddress(ip.getId()).isEmpty()) { + throw new InvalidParameterValueException(String.format( + "The requested IP %s cannot be used as the VPN gateway IP as it is already in use by static NAT or network rules", ip.getAddress().addr())); + } + return ip; + } + + private IPAddressVO allocateVpnGatewayIp(Vpc vpc) { + Account owner = accountMgr.getAccount(vpc.getAccountId()); + DataCenterVO zone = dataCenterDao.findById(vpc.getZoneId()); + IpAddress allocatedIp = null; + try { + allocatedIp = ipAddressManager.allocateIp(owner, false, CallContext.current().getCallingAccount(), + CallContext.current().getCallingUser(), zone, null, null); + vpcService.associateIPToVpc(allocatedIp.getId(), vpc.getId()); + userIpAddressDetailsDao.addDetail(allocatedIp.getId(), NSX_VPN_GATEWAY_IP_DETAIL, "true", false); + IPAddressVO ip = ipAddressDao.findById(allocatedIp.getId()); + if (ip == null) { + throw new CloudRuntimeException(String.format("The allocated VPN gateway IP %s could not be loaded after association", + allocatedIp.getId())); + } + if (ip.isSourceNat()) { + throw new CloudRuntimeException(String.format( + "The allocated IP %s became a source NAT IP when it was associated to VPC %s; it cannot be used as a dedicated VPN endpoint", + ip.getAddress(), vpc.getName())); + } + return ip; + } catch (Exception e) { + // do not leak the IP when associating or tagging it fails after allocation succeeded + if (Objects.nonNull(allocatedIp)) { + IPAddressVO ipToRelease = ipAddressDao.findById(allocatedIp.getId()); + if (Objects.nonNull(ipToRelease)) { + try { + releaseAutoAcquiredVpnGatewayIp(ipToRelease); + } catch (Exception releaseException) { + logger.warn("Failed to release the IP {} allocated for the VPN gateway of VPC {}: {}", + ipToRelease.getAddress().addr(), vpc.getName(), releaseException.getMessage()); + } + } else { + try { + ipAddressManager.disassociatePublicIpAddress(allocatedIp, CallContext.current().getCallingUserId(), + CallContext.current().getCallingAccount()); + } catch (Exception releaseException) { + logger.warn("Failed to release allocated VPN gateway IP {} of VPC {} after its database row disappeared: {}", + allocatedIp.getId(), vpc.getName(), releaseException.getMessage()); + } + } + } + throw new CloudRuntimeException(String.format("Failed to acquire an IP for the VPN gateway of VPC %s: %s", + vpc.getName(), e.getMessage()), e); + } + } + + private void releaseAutoAcquiredVpnGatewayIp(IPAddressVO ip) { + boolean disassociated = ipAddressManager.disassociatePublicIpAddress(ip, CallContext.current().getCallingUserId(), + CallContext.current().getCallingAccount()); + if (!disassociated) { + throw new CloudRuntimeException(String.format("Failed to disassociate auto-acquired VPN gateway IP %s", ip.getAddress())); + } + userIpAddressDetailsDao.removeDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL); + } + + @Override + public void releaseVpnGatewayIp(Site2SiteVpnGateway gateway) { + VpcVO vpc = vpcDao.findById(gateway.getVpcId()); + if (vpc != null) { + try { + if (!nsxService.deleteVpnGateway(vpc)) { + throw new CloudRuntimeException(String.format("The NSX VPN gateway service for VPC %s was not deleted: the provider returned an unsuccessful answer", + vpc.getName())); + } + } catch (Exception e) { + throw new CloudRuntimeException(String.format("Failed to delete the NSX VPN gateway of VPC %s: %s", + vpc.getName(), e.getMessage()), e); + } + } + IPAddressVO ip = ipAddressDao.findById(gateway.getAddrId()); + if (Objects.isNull(ip)) { + return; + } + UserIpAddressDetailVO autoAcquiredDetail = userIpAddressDetailsDao.findDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL); + if (Objects.isNull(autoAcquiredDetail)) { + return; + } + if (Boolean.parseBoolean(autoAcquiredDetail.getValue())) { + logger.debug("Releasing the auto-acquired VPN gateway IP {} of VPC {}", ip.getAddress().addr(), + vpc == null ? gateway.getVpcId() : vpc.getName()); + releaseAutoAcquiredVpnGatewayIp(ip); + } else { + // The marker is provider ownership state, not a permanent attribute of the address. + // Remove it after the NSX objects are gone so a later gateway using this IP cannot be + // mistaken for an NSX-owned gateway during offering-change cleanup. + userIpAddressDetailsDao.removeDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL); + } + } + + @Override + public boolean ownsVpnGateway(Site2SiteVpnGateway gateway) { + if (gateway == null) { + return false; + } + return userIpAddressDetailsDao.findDetail(gateway.getAddrId(), NSX_VPN_GATEWAY_IP_DETAIL) != null; + } + + @Override + public void validateSite2SiteVpnCustomerGateway(Site2SiteCustomerGateway customerGateway) { + if (!NetUtils.isValidIp4(customerGateway.getGatewayIp())) { + throw new InvalidParameterValueException(String.format( + "NSX Site-to-Site VPN requires an IPv4 peer address; customer gateway %s uses %s", + customerGateway.getName(), customerGateway.getGatewayIp())); + } + NsxVpnCryptoUtils.validate(customerGateway.getIkePolicy(), customerGateway.getEspPolicy(), + customerGateway.getIkeVersion(), customerGateway.getIkeLifetime(), customerGateway.getEspLifetime(), + customerGateway.getIpsecPsk()); + } + + @Override + public boolean startSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUnavailableException { + Site2SiteVpnGatewayVO vpnGateway = vpnGatewayDao.findById(conn.getVpnGatewayId()); + if (Objects.isNull(vpnGateway)) { + throw new CloudRuntimeException(String.format( + "Cannot find the VPN gateway %s of the Site-to-Site VPN connection %s", conn.getVpnGatewayId(), conn.getUuid())); + } + VpcVO vpc = vpcDao.findById(vpnGateway.getVpcId()); + if (Objects.isNull(vpc)) { + throw new CloudRuntimeException(String.format( + "Cannot find the VPC %s of the VPN gateway of Site-to-Site VPN connection %s", + vpnGateway.getVpcId(), conn.getUuid())); + } + if (!isVpnProvidedByNsx(vpc, vpnGateway)) { + return true; + } + Site2SiteCustomerGatewayVO customerGateway = customerGatewayDao.findById(conn.getCustomerGatewayId()); + if (Objects.isNull(customerGateway)) { + throw new CloudRuntimeException(String.format( + "Cannot find the customer gateway %s of the Site-to-Site VPN connection %s", conn.getCustomerGatewayId(), conn.getUuid())); + } + validateSite2SiteVpnCustomerGateway(customerGateway); + if (BooleanUtils.isTrue(customerGateway.getEncap())) { + logger.debug("Ignoring forceencap for the NSX VPN connection {}: NSX negotiates NAT-T automatically", conn); + } + if (BooleanUtils.isTrue(customerGateway.getSplitConnections())) { + logger.debug("Ignoring splitconnections for the NSX VPN connection {}: a route-based session carries all subnets", conn); + } + IPAddressVO localEndpointIp = ipAddressDao.findById(vpnGateway.getAddrId()); + if (Objects.isNull(localEndpointIp)) { + throw new CloudRuntimeException(String.format( + "Cannot find the local endpoint IP %s of the VPN gateway of VPC %s", vpnGateway.getAddrId(), vpc.getName())); + } + Pair vtiAddresses = NsxHelper.getVpnVtiAddressPair(conn.getId()); + List peerCidrs = Arrays.stream(customerGateway.getGuestCidrList().split(",")) + .map(String::trim) + .collect(Collectors.toList()); + return nsxService.createVpnConnection(vpc, conn.getUuid(), customerGateway.getGatewayIp(), + customerGateway.getIpsecPsk(), customerGateway.getIkePolicy(), customerGateway.getEspPolicy(), + customerGateway.getIkeLifetime(), customerGateway.getEspLifetime(), + BooleanUtils.isTrue(customerGateway.getDpd()), customerGateway.getIkeVersion(), conn.isPassive(), + peerCidrs, vtiAddresses.first(), vtiAddresses.second(), NsxHelper.VPN_VTI_PREFIX_LENGTH, + localEndpointIp.getAddress().addr()); + } + + @Override + public boolean stopSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUnavailableException { + Site2SiteVpnGatewayVO vpnGateway = vpnGatewayDao.findById(conn.getVpnGatewayId()); + if (Objects.isNull(vpnGateway)) { + throw new CloudRuntimeException(String.format( + "Cannot find the VPN gateway %s of the Site-to-Site VPN connection %s", conn.getVpnGatewayId(), conn.getUuid())); + } + VpcVO vpc = vpcDao.findById(vpnGateway.getVpcId()); + if (Objects.isNull(vpc)) { + throw new CloudRuntimeException(String.format( + "Cannot find the VPC %s of the VPN gateway of Site-to-Site VPN connection %s", + vpnGateway.getVpcId(), conn.getUuid())); + } + if (!isVpnProvidedByNsx(vpc, vpnGateway)) { + return true; + } + return nsxService.updateVpnConnectionState(vpc, conn.getUuid(), false); + } + + @Override + public boolean deleteSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUnavailableException { + Site2SiteVpnGatewayVO vpnGateway = vpnGatewayDao.findById(conn.getVpnGatewayId()); + if (Objects.isNull(vpnGateway)) { + throw new CloudRuntimeException(String.format( + "Cannot find the VPN gateway %s of the Site-to-Site VPN connection %s", conn.getVpnGatewayId(), conn.getUuid())); + } + VpcVO vpc = vpcDao.findById(vpnGateway.getVpcId()); + if (vpc == null) { + return true; + } + return nsxService.deleteVpnConnection(vpc, conn.getUuid()); + } } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java index d48def5c9bd5..3f0fed6098cb 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java @@ -16,10 +16,18 @@ // under the License. package org.apache.cloudstack.service; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import javax.inject.Inject; +import javax.naming.ConfigurationException; import org.apache.cloudstack.NsxAnswer; import org.apache.cloudstack.agent.api.CreateNsxDistributedFirewallRulesCommand; @@ -27,38 +35,122 @@ import org.apache.cloudstack.agent.api.CreateNsxPortForwardRuleCommand; import org.apache.cloudstack.agent.api.CreateNsxStaticNatCommand; import org.apache.cloudstack.agent.api.CreateNsxTier1GatewayCommand; +import org.apache.cloudstack.agent.api.CreateNsxVpnConnectionCommand; +import org.apache.cloudstack.agent.api.CreateNsxVpnGatewayCommand; import org.apache.cloudstack.agent.api.CreateOrUpdateNsxTier1NatRuleCommand; import org.apache.cloudstack.agent.api.DeleteNsxDistributedFirewallRulesCommand; import org.apache.cloudstack.agent.api.DeleteNsxLoadBalancerRuleCommand; import org.apache.cloudstack.agent.api.DeleteNsxNatRuleCommand; import org.apache.cloudstack.agent.api.DeleteNsxSegmentCommand; import org.apache.cloudstack.agent.api.DeleteNsxTier1GatewayCommand; +import org.apache.cloudstack.agent.api.DeleteNsxVpnConnectionCommand; +import org.apache.cloudstack.agent.api.DeleteNsxVpnGatewayCommand; +import org.apache.cloudstack.agent.api.GetNsxVpnSessionStatusCommand; +import org.apache.cloudstack.agent.api.UpdateNsxVpnConnectionStateCommand; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.managed.context.ManagedContextRunnable; import org.apache.cloudstack.resource.NsxNetworkRule; +import org.apache.cloudstack.resourcedetail.dao.UserIpAddressDetailsDao; import org.apache.cloudstack.utils.NsxControllerUtils; import org.apache.cloudstack.utils.NsxHelper; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import com.cloud.alert.AlertManager; import com.cloud.network.IpAddress; import com.cloud.network.Network; import com.cloud.network.SDNProviderNetworkRule; +import com.cloud.network.Site2SiteVpnConnection; import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.Site2SiteVpnConnectionDao; +import com.cloud.network.dao.Site2SiteVpnConnectionVO; +import com.cloud.network.dao.Site2SiteVpnGatewayDao; +import com.cloud.network.dao.Site2SiteVpnGatewayVO; import com.cloud.network.nsx.NsxService; +import com.cloud.network.nsx.NsxVpnGatewayResult; import com.cloud.network.vpc.Vpc; +import com.cloud.network.vpc.VpcManager; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; +import com.cloud.network.vpc.dao.VpcOfferingServiceMapDao; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.concurrency.NamedThreadFactory; import com.cloud.utils.exception.CloudRuntimeException; -public class NsxServiceImpl implements NsxService, Configurable { +public class NsxServiceImpl extends ManagerBase implements NsxService, Configurable { + + public static final ConfigKey NSX_VPN_STATUS_POLL_INTERVAL = new ConfigKey<>("Advanced", Integer.class, + "nsx.vpn.status.poll.interval", "60", + "Interval (in seconds) between two NSX Site-to-Site VPN connection status polls; requires a management server restart", + false, ConfigKey.Scope.Global); + + protected static final String VPN_SESSION_STATUS_UP = "UP"; + protected static final String VPN_SESSION_STATUS_DOWN = "DOWN"; + protected static final String VPN_SESSION_STATUS_DEGRADED = "DEGRADED"; + protected static final String VPN_SESSION_STATUS_NOT_FOUND = "NOT_FOUND"; + protected static final int VPN_STATUS_POLL_FAILURE_THRESHOLD = 3; + protected static final int VPN_STATUS_POLL_MIN_INTERVAL = 10; + protected static final int VPN_STATUS_POLL_DEFAULT_INTERVAL = 60; + + private static final List VPN_POLLED_STATES = List.of( + Site2SiteVpnConnection.State.Pending, Site2SiteVpnConnection.State.Connecting, Site2SiteVpnConnection.State.Connected, + Site2SiteVpnConnection.State.Disconnected); + @Inject NsxControllerUtils nsxControllerUtils; @Inject VpcDao vpcDao; + @Inject + VpcOfferingServiceMapDao vpcOfferingServiceMapDao; + @Inject + VpcManager vpcManager; + @Inject + Site2SiteVpnConnectionDao site2SiteVpnConnectionDao; + @Inject + Site2SiteVpnGatewayDao site2SiteVpnGatewayDao; + @Inject + UserIpAddressDetailsDao userIpAddressDetailsDao; + @Inject + AlertManager alertManager; protected Logger logger = LogManager.getLogger(getClass()); + private ScheduledExecutorService vpnStatusPollExecutor; + private final Map vpnStatusPollFailures = new ConcurrentHashMap<>(); + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + super.configure(name, params); + vpnStatusPollExecutor = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("Nsx-Vpn-Status-Poll")); + return true; + } + + @Override + public boolean start() { + super.start(); + if (vpnStatusPollExecutor == null) { + throw new IllegalStateException("NSX VPN status poller was not configured"); + } + Integer configuredInterval = NSX_VPN_STATUS_POLL_INTERVAL.value(); + int pollInterval = Objects.isNull(configuredInterval) ? VPN_STATUS_POLL_DEFAULT_INTERVAL : configuredInterval; + if (pollInterval < VPN_STATUS_POLL_MIN_INTERVAL) { + logger.warn("The configured value {} of {} is below the minimum of {} seconds, using the default of {} seconds", + configuredInterval, NSX_VPN_STATUS_POLL_INTERVAL.key(), VPN_STATUS_POLL_MIN_INTERVAL, VPN_STATUS_POLL_DEFAULT_INTERVAL); + pollInterval = VPN_STATUS_POLL_DEFAULT_INTERVAL; + } + vpnStatusPollExecutor.scheduleWithFixedDelay(new VpnStatusPollTask(), pollInterval, pollInterval, TimeUnit.SECONDS); + return true; + } + + @Override + public boolean stop() { + if (Objects.nonNull(vpnStatusPollExecutor)) { + vpnStatusPollExecutor.shutdownNow(); + } + return super.stop(); + } + public boolean createVpcNetwork(Long zoneId, long accountId, long domainId, Long vpcId, String vpcName, boolean sourceNatEnabled) { CreateNsxTier1GatewayCommand createNsxTier1GatewayCommand = new CreateNsxTier1GatewayCommand(domainId, accountId, zoneId, vpcId, vpcName, true, sourceNatEnabled); @@ -196,6 +288,180 @@ public boolean deleteFirewallRules(Network network, List netRule return result.getResult(); } + public NsxVpnGatewayResult createVpnGateway(Vpc vpc, String localEndpointIp) { + CreateNsxVpnGatewayCommand createNsxVpnGatewayCommand = new CreateNsxVpnGatewayCommand(vpc.getDomainId(), + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), localEndpointIp); + NsxAnswer result = nsxControllerUtils.sendNsxCommandForResult(createNsxVpnGatewayCommand, vpc.getZoneId()); + return new NsxVpnGatewayResult(result.getResult(), result.isEndpointMayBeInUse()); + } + + public boolean deleteVpnGateway(Vpc vpc) { + DeleteNsxVpnGatewayCommand deleteNsxVpnGatewayCommand = new DeleteNsxVpnGatewayCommand(vpc.getDomainId(), + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName()); + NsxAnswer result = nsxControllerUtils.sendNsxCommand(deleteNsxVpnGatewayCommand, vpc.getZoneId()); + return result.getResult(); + } + + public boolean createVpnConnection(Vpc vpc, String connectionUuid, String peerAddress, String psk, + String ikePolicy, String espPolicy, Long ikeLifetime, Long espLifetime, + boolean dpdEnabled, String ikeVersion, boolean passive, List peerCidrs, + String vtiLocalIp, String vtiPeerIp, int vtiPrefixLength, String localEndpointIp) { + CreateNsxVpnConnectionCommand createNsxVpnConnectionCommand = new CreateNsxVpnConnectionCommand(vpc.getDomainId(), + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionUuid, peerAddress, psk, + ikePolicy, espPolicy, ikeLifetime, espLifetime, dpdEnabled, ikeVersion, passive, peerCidrs, + vtiLocalIp, vtiPeerIp, vtiPrefixLength, vpc.getCidr(), localEndpointIp); + NsxAnswer result = nsxControllerUtils.sendNsxCommand(createNsxVpnConnectionCommand, vpc.getZoneId()); + return result.getResult(); + } + + public boolean deleteVpnConnection(Vpc vpc, String connectionUuid) { + DeleteNsxVpnConnectionCommand deleteNsxVpnConnectionCommand = new DeleteNsxVpnConnectionCommand(vpc.getDomainId(), + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionUuid); + NsxAnswer result = nsxControllerUtils.sendNsxCommand(deleteNsxVpnConnectionCommand, vpc.getZoneId()); + return result.getResult(); + } + + public boolean updateVpnConnectionState(Vpc vpc, String connectionUuid, boolean enabled) { + UpdateNsxVpnConnectionStateCommand command = new UpdateNsxVpnConnectionStateCommand(vpc.getDomainId(), + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionUuid, enabled); + NsxAnswer result = nsxControllerUtils.sendNsxCommand(command, vpc.getZoneId()); + return result.getResult(); + } + + public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + GetNsxVpnSessionStatusCommand getNsxVpnSessionStatusCommand = new GetNsxVpnSessionStatusCommand(vpc.getDomainId(), + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionUuid); + NsxAnswer result = nsxControllerUtils.sendNsxCommand(getNsxVpnSessionStatusCommand, vpc.getZoneId()); + return result.getDetails(); + } + + /** + * Every management server runs this poller over all NSX-provided VPN connections; the + * duplicate polling in a multi-server setup is tolerated, as state transitions are + * serialized by the row lock in transitionVpnConnectionState + */ + protected class VpnStatusPollTask extends ManagedContextRunnable { + @Override + protected void runInContext() { + try { + Set polledConnectionIds = new HashSet<>(); + List connections = site2SiteVpnConnectionDao.listAll(); + for (Site2SiteVpnConnectionVO connection : connections) { + if (!VPN_POLLED_STATES.contains(connection.getState())) { + continue; + } + Site2SiteVpnGatewayVO vpnGateway = site2SiteVpnGatewayDao.findById(connection.getVpnGatewayId()); + if (vpnGateway == null) { + continue; + } + VpcVO vpc = vpcDao.findById(vpnGateway.getVpcId()); + if (vpc == null || !isVpnProvidedByNsx(vpc, vpnGateway)) { + continue; + } + polledConnectionIds.add(connection.getId()); + pollVpnConnectionStatus(connection, vpc); + } + // Drop the failure counters of connections that were deleted or left the polled states + vpnStatusPollFailures.keySet().retainAll(polledConnectionIds); + } catch (Exception e) { + logger.warn("Failed to poll the status of the NSX Site-to-Site VPN connections: {}", e.getMessage(), e); + } + } + } + + private boolean isVpnProvidedByNsx(Vpc vpc) { + if (vpcManager != null) { + return vpcManager.isProviderSupportServiceInVpc(vpc.getId(), Network.Service.Vpn, Network.Provider.Nsx); + } + return vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId( + Network.Service.Vpn.getName(), Network.Provider.Nsx.getName(), vpc.getVpcOfferingId()) != null; + } + + private boolean isVpnProvidedByNsx(Vpc vpc, Site2SiteVpnGatewayVO vpnGateway) { + if (isVpnProvidedByNsx(vpc)) { + return true; + } + return userIpAddressDetailsDao != null + && vpnGateway != null + && userIpAddressDetailsDao.findDetail(vpnGateway.getAddrId(), NsxElement.NSX_VPN_GATEWAY_IP_DETAIL) != null; + } + + protected void pollVpnConnectionStatus(Site2SiteVpnConnectionVO connection, VpcVO vpc) { + String status; + try { + status = getVpnConnectionStatus(vpc, connection.getUuid()); + vpnStatusPollFailures.remove(connection.getId()); + } catch (Exception e) { + int failures = vpnStatusPollFailures.merge(connection.getId(), 1, Integer::sum); + logger.warn("Failed to get the status of the NSX VPN connection {} of VPC {} ({} consecutive failure(s)): {}", + connection, vpc, failures, e.getMessage()); + if (failures >= VPN_STATUS_POLL_FAILURE_THRESHOLD) { + // A failed status query says nothing about the tunnel itself: alert, but never + // transition the connection state on management-plane errors + String title = String.format("Unable to poll the status of Site-to-site Vpn Connection %s", connection.getUuid()); + String context = String.format( + "The status of Site-to-site Vpn Connection %s on the NSX tier-1 gateway of VPC %s could not be polled %d consecutive times; its state %s is left unchanged", + connection.getUuid(), vpc.getName(), failures, connection.getState()); + logger.warn(context); + alertManager.sendAlert(AlertManager.AlertType.ALERT_TYPE_DOMAIN_ROUTER, vpc.getZoneId(), null, title, context); + vpnStatusPollFailures.remove(connection.getId()); + } + return; + } + Site2SiteVpnConnection.State newState; + if (VPN_SESSION_STATUS_UP.equals(status)) { + newState = Site2SiteVpnConnection.State.Connected; + } else if (VPN_SESSION_STATUS_DOWN.equals(status) || VPN_SESSION_STATUS_DEGRADED.equals(status)) { + newState = Site2SiteVpnConnection.State.Disconnected; + } else if (VPN_SESSION_STATUS_NOT_FOUND.equals(status)) { + if (connection.getState() == Site2SiteVpnConnection.State.Pending + || connection.getState() == Site2SiteVpnConnection.State.Connecting) { + // the async connection job may still be creating the session on NSX + return; + } + if (connection.getState() == Site2SiteVpnConnection.State.Disconnected) { + // stop intentionally disables the session; a subsequent status lookup may report it + // as absent while the connection remains a valid, stopped CloudStack resource + return; + } + // an established session vanished from NSX: flag the connection for a manual reset + newState = Site2SiteVpnConnection.State.Error; + } else { + logger.debug("NSX VPN connection {} of VPC {} reported the status {}, not transitioning the state", connection, vpc, status); + return; + } + transitionVpnConnectionState(connection, vpc, newState); + } + + protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, Site2SiteVpnConnection.State newState) { + if (connection.getState() == newState) { + return; + } + Site2SiteVpnConnectionVO lock = site2SiteVpnConnectionDao.acquireInLockTable(connection.getId()); + if (lock == null) { + logger.warn("Unable to acquire the lock for the NSX Site-to-Site VPN connection {}, not updating its state", connection); + return; + } + try { + Site2SiteVpnConnectionVO lockedConnection = site2SiteVpnConnectionDao.findById(connection.getId()); + if (lockedConnection == null || !VPN_POLLED_STATES.contains(lockedConnection.getState()) + || lockedConnection.getState() == newState) { + return; + } + Site2SiteVpnConnection.State oldState = lockedConnection.getState(); + lockedConnection.setState(newState); + site2SiteVpnConnectionDao.persist(lockedConnection); + vpnStatusPollFailures.remove(lockedConnection.getId()); + String title = String.format("Site-to-site Vpn Connection %s just switched from %s to %s", lockedConnection.getUuid(), oldState, newState); + String context = String.format("Site-to-site Vpn Connection %s on the NSX tier-1 gateway of VPC %s just switched from %s to %s", + lockedConnection.getUuid(), vpc.getName(), oldState, newState); + logger.info(context); + alertManager.sendAlert(AlertManager.AlertType.ALERT_TYPE_DOMAIN_ROUTER, vpc.getZoneId(), null, title, context); + } finally { + site2SiteVpnConnectionDao.releaseFromLockTable(lock.getId()); + } + } + @Override public String getConfigComponentName() { return NsxApiClient.class.getSimpleName(); @@ -204,7 +470,7 @@ public String getConfigComponentName() { @Override public ConfigKey[] getConfigKeys() { return new ConfigKey[] { - NSX_API_FAILURE_RETRIES, NSX_API_FAILURE_INTERVAL + NSX_API_FAILURE_RETRIES, NSX_API_FAILURE_INTERVAL, NSX_VPN_STATUS_POLL_INTERVAL }; } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxControllerUtils.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxControllerUtils.java index f44364f34c8b..278ac1fa4f98 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxControllerUtils.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxControllerUtils.java @@ -51,6 +51,15 @@ public static String getNsxDistributedFirewallPolicyRuleId(String segmentName, l } public NsxAnswer sendNsxCommand(NsxCommand cmd, long zoneId) throws IllegalArgumentException { + NsxAnswer answer = sendNsxCommandForResult(cmd, zoneId); + if (!answer.getResult()) { + logger.error("NSX API Command failed"); + throw new InvalidParameterValueException("Failed API call to NSX controller"); + } + return answer; + } + + public NsxAnswer sendNsxCommandForResult(NsxCommand cmd, long zoneId) throws IllegalArgumentException { NsxProviderVO nsxProviderVO = nsxProviderDao.findByZoneId(zoneId); if (nsxProviderVO == null) { logger.error("No NSX controller was found!"); @@ -58,7 +67,7 @@ public NsxAnswer sendNsxCommand(NsxCommand cmd, long zoneId) throws IllegalArgum } Answer answer = agentMgr.easySend(nsxProviderVO.getHostId(), cmd); - if (answer == null || !answer.getResult()) { + if (answer == null) { logger.error("NSX API Command failed"); throw new InvalidParameterValueException("Failed API call to NSX controller"); } @@ -138,6 +147,50 @@ public static String getServerPoolMemberName(String tier1GatewayName, long vmId) return tier1GatewayName + "-VM" + vmId; } + public static String getVpnServiceName(String tier1GatewayName) { + return tier1GatewayName + "-vpn"; + } + + public static String getVpnLocalEndpointName(String vpnServiceName) { + return vpnServiceName + "-le"; + } + + public static String getVpnLocalEndpointNoSnatRuleName(String vpnServiceName) { + return vpnServiceName + "-le-nosnat"; + } + + public static String getVpnSessionName(String connectionUuid) { + return "cs-conn-" + connectionUuid; + } + + public static String getVpnIkeProfileName(String connectionUuid) { + return getVpnSessionName(connectionUuid) + "-ike"; + } + + public static String getVpnEspProfileName(String connectionUuid) { + return getVpnSessionName(connectionUuid) + "-esp"; + } + + public static String getVpnDpdProfileName(String connectionUuid) { + return getVpnSessionName(connectionUuid) + "-dpd"; + } + + public static String getVpnStaticRouteNamePrefix(String connectionUuid) { + return getVpnSessionName(connectionUuid) + "-route"; + } + + public static String getVpnStaticRouteName(String connectionUuid, int peerCidrIndex) { + return getVpnStaticRouteNamePrefix(connectionUuid) + peerCidrIndex; + } + + public static String getVpnNoSnatRuleNamePrefix(String connectionUuid) { + return getVpnSessionName(connectionUuid) + "-nosnat"; + } + + public static String getVpnNoSnatRuleName(String connectionUuid, int peerCidrIndex) { + return getVpnNoSnatRuleNamePrefix(connectionUuid) + peerCidrIndex; + } + public static String getLoadBalancerAlgorithm(String algorithm) { switch (algorithm) { case "leastconn": diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxHelper.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxHelper.java index b0668a0704f9..625bd4be8040 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxHelper.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxHelper.java @@ -22,6 +22,8 @@ import com.cloud.network.dao.NetworkVO; import com.cloud.network.vpc.VpcVO; import com.cloud.user.Account; +import com.cloud.utils.Pair; +import com.cloud.utils.net.NetUtils; import org.apache.cloudstack.agent.api.CreateNsxDhcpRelayConfigCommand; import org.apache.cloudstack.agent.api.CreateNsxSegmentCommand; import org.apache.cloudstack.agent.api.CreateOrUpdateNsxTier1NatRuleCommand; @@ -30,9 +32,27 @@ public class NsxHelper { + public static final int VPN_VTI_PREFIX_LENGTH = 30; + + private static final long VPN_VTI_SUBNET_BASE = NetUtils.ip2Long("169.254.64.0"); + // 169.254.64.0/18 provides 4096 /30 slots + private static final long VPN_VTI_SUBNET_SLOTS = 4096L; + private NsxHelper() { } + /** + * Derives the preferred VTI /30 for a Site-to-Site VPN connection from its database id: + * 169.254.64.0/18 base + 4 x (id mod 4096), local = .1 and peer = .2 within the /30. + * A collision is rejected rather than silently selecting another subnet: the peer must be + * configured with this deterministic pair, and CloudStack has no API field in which to persist + * an alternative allocation. + */ + public static Pair getVpnVtiAddressPair(long connectionId) { + long slotBase = VPN_VTI_SUBNET_BASE + (connectionId % VPN_VTI_SUBNET_SLOTS) * 4; + return new Pair<>(NetUtils.long2Ip(slotBase + 1), NetUtils.long2Ip(slotBase + 2)); + } + public static CreateNsxDhcpRelayConfigCommand createNsxDhcpRelayConfigCommand(DomainVO domain, Account account, DataCenter zone, VpcVO vpc, Network network, List addresses) { Long vpcId = vpc != null ? vpc.getId() : null; String vpcName = vpc != null ? vpc.getName() : null; diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxVpnCryptoUtils.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxVpnCryptoUtils.java new file mode 100644 index 000000000000..e4224ac934ed --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxVpnCryptoUtils.java @@ -0,0 +1,181 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.utils; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; + +import com.cloud.exception.InvalidParameterValueException; + +/** + * Maps CloudStack Site-to-Site VPN policy strings (a comma-separated list of + * "cipher-hash[;dhgroup]" proposals) to the NSX IPSec VPN profile enums, rejecting + * parameters NSX does not support. + */ +public class NsxVpnCryptoUtils { + + public static final long NSX_MIN_IKE_SA_LIFETIME = 21600L; + public static final long NSX_MIN_ESP_SA_LIFETIME = 900L; + public static final long NSX_MAX_ESP_SA_LIFETIME = 31536000L; + public static final int NSX_MAX_PSK_LENGTH = 128; + + private static final Map ENCRYPTION_ALGORITHM_MAP = Map.of( + "aes128", "AES_128", + "aes256", "AES_256"); + + private static final Map DIGEST_ALGORITHM_MAP = Map.of( + "sha1", "SHA1", + "sha256", "SHA2_256", + "sha384", "SHA2_384", + "sha512", "SHA2_512"); + + private static final Map DH_GROUP_MAP = Map.of( + "modp1024", "GROUP2", + "modp1536", "GROUP5", + "modp2048", "GROUP14", + "modp3072", "GROUP15", + "modp4096", "GROUP16"); + + private static final Map IKE_VERSION_MAP = Map.of( + "ike", "IKE_FLEX", + "ikev1", "IKE_V1", + "ikev2", "IKE_V2"); + + private NsxVpnCryptoUtils() { + } + + public static List getEncryptionAlgorithms(String policy) { + Set algorithms = new LinkedHashSet<>(); + for (String entry : getPolicyEntries(policy)) { + String token = entry.split(";")[0].split("-")[0].toLowerCase(Locale.ROOT); + String nsxAlgorithm = ENCRYPTION_ALGORITHM_MAP.get(token); + if (nsxAlgorithm == null) { + throw new InvalidParameterValueException(String.format( + "Encryption algorithm %s is not supported by NSX Site-to-Site VPN (supported: %s)", + token, "aes128, aes256")); + } + algorithms.add(nsxAlgorithm); + } + return new ArrayList<>(algorithms); + } + + public static List getDigestAlgorithms(String policy) { + Set algorithms = new LinkedHashSet<>(); + for (String entry : getPolicyEntries(policy)) { + String[] tokens = entry.split(";")[0].split("-"); + if (tokens.length < 2) { + throw new InvalidParameterValueException(String.format( + "Missing hash algorithm in VPN policy entry %s", entry)); + } + String token = tokens[1].toLowerCase(Locale.ROOT); + String nsxAlgorithm = DIGEST_ALGORITHM_MAP.get(token); + if (nsxAlgorithm == null) { + throw new InvalidParameterValueException(String.format( + "Hash algorithm %s is not supported by NSX Site-to-Site VPN (supported: %s)", + token, "sha1, sha256, sha384, sha512")); + } + algorithms.add(nsxAlgorithm); + } + return new ArrayList<>(algorithms); + } + + public static List getDhGroups(String policy) { + Set groups = new LinkedHashSet<>(); + for (String entry : getPolicyEntries(policy)) { + String[] tokens = entry.split(";"); + if (tokens.length < 2 || StringUtils.isBlank(tokens[1])) { + continue; + } + String dhGroup = tokens[1].trim().toLowerCase(Locale.ROOT); + String nsxGroup = DH_GROUP_MAP.get(dhGroup); + if (nsxGroup == null) { + throw new InvalidParameterValueException(String.format( + "Diffie-Hellman group %s is not supported by NSX Site-to-Site VPN (supported: %s)", + dhGroup, "modp1024, modp1536, modp2048, modp3072, modp4096")); + } + groups.add(nsxGroup); + } + return new ArrayList<>(groups); + } + + public static String getIkeVersion(String ikeVersion) { + String token = StringUtils.isBlank(ikeVersion) ? "ike" : ikeVersion.toLowerCase(Locale.ROOT); + String nsxIkeVersion = IKE_VERSION_MAP.get(token); + if (nsxIkeVersion == null) { + throw new InvalidParameterValueException(String.format( + "IKE version %s is not supported by NSX Site-to-Site VPN (supported: %s)", + token, "ike, ikev1, ikev2")); + } + return nsxIkeVersion; + } + + public static void validateIkeLifetime(Long ikeLifetime) { + if (ikeLifetime != null && ikeLifetime < NSX_MIN_IKE_SA_LIFETIME) { + throw new InvalidParameterValueException(String.format( + "IKE lifetime %s is below the NSX minimum of %s seconds", ikeLifetime, NSX_MIN_IKE_SA_LIFETIME)); + } + } + + public static void validateEspLifetime(Long espLifetime) { + if (espLifetime != null && (espLifetime < NSX_MIN_ESP_SA_LIFETIME || espLifetime > NSX_MAX_ESP_SA_LIFETIME)) { + throw new InvalidParameterValueException(String.format( + "ESP lifetime %s is outside the NSX supported range of %s-%s seconds", + espLifetime, NSX_MIN_ESP_SA_LIFETIME, NSX_MAX_ESP_SA_LIFETIME)); + } + } + + public static void validatePresharedKey(String psk) { + if (StringUtils.isBlank(psk)) { + throw new InvalidParameterValueException("A pre-shared key is required for NSX Site-to-Site VPN"); + } + if (psk.length() > NSX_MAX_PSK_LENGTH) { + throw new InvalidParameterValueException(String.format( + "The pre-shared key exceeds the NSX maximum length of %s characters", NSX_MAX_PSK_LENGTH)); + } + } + + public static void validate(String ikePolicy, String espPolicy, String ikeVersion, + Long ikeLifetime, Long espLifetime, String psk) { + getEncryptionAlgorithms(ikePolicy); + getDigestAlgorithms(ikePolicy); + getDhGroups(ikePolicy); + getEncryptionAlgorithms(espPolicy); + getDigestAlgorithms(espPolicy); + getDhGroups(espPolicy); + getIkeVersion(ikeVersion); + validateIkeLifetime(ikeLifetime); + validateEspLifetime(espLifetime); + validatePresharedKey(psk); + } + + private static String[] getPolicyEntries(String policy) { + if (StringUtils.isBlank(policy)) { + throw new InvalidParameterValueException("An empty VPN policy cannot be mapped to NSX"); + } + String[] entries = policy.split(","); + for (int i = 0; i < entries.length; i++) { + entries[i] = entries[i].trim(); + } + return entries; + } +} diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java index 0d74bb8a3b3d..24b94c1553d7 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java @@ -16,6 +16,8 @@ // under the License. package org.apache.cloudstack.resource; +import com.cloud.agent.api.Command; +import com.cloud.serializer.GsonHelper; import com.cloud.network.Network; import com.cloud.network.dao.NetworkVO; import com.cloud.utils.exception.CloudRuntimeException; @@ -32,11 +34,17 @@ import org.apache.cloudstack.agent.api.CreateNsxStaticNatCommand; import org.apache.cloudstack.agent.api.CreateNsxTier1GatewayCommand; import org.apache.cloudstack.agent.api.CreateOrUpdateNsxTier1NatRuleCommand; +import org.apache.cloudstack.agent.api.CreateNsxVpnConnectionCommand; +import org.apache.cloudstack.agent.api.CreateNsxVpnGatewayCommand; import org.apache.cloudstack.agent.api.DeleteNsxDistributedFirewallRulesCommand; import org.apache.cloudstack.agent.api.DeleteNsxNatRuleCommand; import org.apache.cloudstack.agent.api.DeleteNsxSegmentCommand; import org.apache.cloudstack.agent.api.DeleteNsxTier1GatewayCommand; +import org.apache.cloudstack.agent.api.DeleteNsxVpnConnectionCommand; +import org.apache.cloudstack.agent.api.DeleteNsxVpnGatewayCommand; +import org.apache.cloudstack.agent.api.GetNsxVpnSessionStatusCommand; import org.apache.cloudstack.agent.api.NsxCommand; +import org.apache.cloudstack.agent.api.UpdateNsxVpnConnectionStateCommand; import org.apache.cloudstack.service.NsxApiClient; import org.apache.cloudstack.utils.NsxControllerUtils; import org.junit.After; @@ -53,15 +61,20 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -296,4 +309,125 @@ public void testCreateTier1NatRule() { NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); assertTrue(answer.getResult()); } + + @Test + public void testCreateNsxVpnGatewayRollsBackAfterFailure() { + CreateNsxVpnGatewayCommand command = new CreateNsxVpnGatewayCommand(domainId, accountId, zoneId, + 3L, "VPC01", "203.0.113.20"); + doThrow(new CloudRuntimeException("ERROR")).when(nsxApi).createVpnService(anyString(), anyString()); + + NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); + + assertFalse(answer.getResult()); + assertFalse(answer.isObjectExistent()); + assertFalse(answer.isEndpointMayBeInUse()); + verify(nsxApi).deleteVpnService("D1-A2-Z1-V3"); + } + + @Test + public void testCreateNsxVpnGatewayRefusesToAdoptExistingService() { + CreateNsxVpnGatewayCommand command = new CreateNsxVpnGatewayCommand(domainId, accountId, zoneId, + 3L, "VPC01", "203.0.113.20"); + when(nsxApi.isVpnServicePresent("D1-A2-Z1-V3")).thenReturn(true); + + NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); + + assertFalse(answer.getResult()); + assertTrue(answer.isObjectExistent()); + assertFalse(answer.isEndpointMayBeInUse()); + verify(nsxApi, never()).createVpnService(anyString(), anyString()); + verify(nsxApi, never()).deleteVpnService("D1-A2-Z1-V3"); + } + + @Test + public void testCreateNsxVpnGatewayReportsPossibleResourceWhenRollbackFails() { + CreateNsxVpnGatewayCommand command = new CreateNsxVpnGatewayCommand(domainId, accountId, zoneId, + 3L, "VPC01", "203.0.113.20"); + doThrow(new CloudRuntimeException("create failed")).when(nsxApi).createVpnService(anyString(), anyString()); + doThrow(new CloudRuntimeException("rollback failed")).when(nsxApi).deleteVpnService("D1-A2-Z1-V3"); + + NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); + + assertFalse(answer.getResult()); + assertTrue(answer.isEndpointMayBeInUse()); + } + + @Test + public void testCreateNsxVpnConnectionRollsBackAfterRouteFailure() { + CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, + 3L, "VPC01", "connection-uuid", "203.0.113.10", "psk", "aes256-sha256;modp2048", + "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, + List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, + "10.1.0.0/16", "203.0.113.20"); + when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyString())).thenReturn(Set.of()); + doThrow(new CloudRuntimeException("ERROR")).when(nsxApi).addVpnConnectionRoutes(anyString(), anyString(), anyList(), anyString(), anyString()); + + NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); + + assertFalse(answer.getResult()); + verify(nsxApi).rollbackVpnConnection("D1-A2-Z1-V3", "connection-uuid"); + } + + @Test + public void testCreateNsxVpnConnectionRejectsVtiCollisionBeforeCreate() { + CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, + 3L, "VPC01", "connection-uuid", "203.0.113.10", "psk", "aes256-sha256;modp2048", + "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, + List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, + "10.1.0.0/16", "203.0.113.20"); + when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyString())).thenReturn(Set.of("169.254.64.21")); + + NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); + + assertFalse(answer.getResult()); + verify(nsxApi, Mockito.never()).createRouteBasedVpnSession(anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyString(), anyInt()); + } + + @Test + public void testNsxVpnLifecycleCommandsDispatch() { + DeleteNsxVpnGatewayCommand deleteGateway = new DeleteNsxVpnGatewayCommand(domainId, accountId, zoneId, 3L, "VPC01"); + DeleteNsxVpnConnectionCommand deleteConnection = new DeleteNsxVpnConnectionCommand(domainId, accountId, zoneId, + 3L, "VPC01", "connection-uuid"); + UpdateNsxVpnConnectionStateCommand update = new UpdateNsxVpnConnectionStateCommand(domainId, accountId, zoneId, + 3L, "VPC01", "connection-uuid", false); + GetNsxVpnSessionStatusCommand status = new GetNsxVpnSessionStatusCommand(domainId, accountId, zoneId, + 3L, "VPC01", "connection-uuid"); + when(nsxApi.getVpnSessionStatus("D1-A2-Z1-V3", "connection-uuid")).thenReturn("UP"); + + assertTrue(((NsxAnswer) nsxResource.executeRequest(deleteGateway)).getResult()); + assertTrue(((NsxAnswer) nsxResource.executeRequest(deleteConnection)).getResult()); + assertTrue(((NsxAnswer) nsxResource.executeRequest(update)).getResult()); + NsxAnswer statusAnswer = (NsxAnswer) nsxResource.executeRequest(status); + assertTrue(statusAnswer.getResult()); + assertTrue(statusAnswer.getDetails().contains("UP")); + verify(nsxApi).deleteVpnService("D1-A2-Z1-V3"); + verify(nsxApi).deleteVpnConnection("D1-A2-Z1-V3", "connection-uuid"); + verify(nsxApi).updateVpnConnectionState("D1-A2-Z1-V3", "connection-uuid", false); + } + + @Test + public void testNsxVpnConnectionCommandRoundTripsThroughCloudStackWireAdaptor() { + CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, + 3L, "VPC01", "connection-uuid", "203.0.113.10", "secret-psk", "aes256-sha256;modp2048", + "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", true, + List.of("192.168.100.0/24", "192.168.101.0/24"), "169.254.64.21", "169.254.64.22", 30, + "10.1.0.0/16", "203.0.113.20"); + + String wire = GsonHelper.getGson().toJson(new Command[]{command}, Command[].class); + String logPayload = GsonHelper.getGsonLogger().toJson(new Command[]{command}, Command[].class); + Command[] decoded = GsonHelper.getGson().fromJson(wire, Command[].class); + + assertTrue(wire.contains("secret-psk")); + assertFalse(logPayload.contains("secret-psk")); + assertFalse(logPayload.contains("\"psk\"")); + assertTrue(decoded[0] instanceof CreateNsxVpnConnectionCommand); + CreateNsxVpnConnectionCommand decodedCommand = (CreateNsxVpnConnectionCommand) decoded[0]; + assertEquals("secret-psk", decodedCommand.getPsk()); + assertEquals(command.getPeerCidrs(), decodedCommand.getPeerCidrs()); + assertEquals(command.getVtiLocalIp(), decodedCommand.getVtiLocalIp()); + assertEquals(command.getVtiPeerIp(), decodedCommand.getVtiPeerIp()); + assertEquals(command.getIkeLifetime(), decodedCommand.getIkeLifetime()); + assertEquals(command.getEspLifetime(), decodedCommand.getEspLifetime()); + } } diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java index 5f8d771f75df..6de675386df9 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java @@ -27,9 +27,22 @@ import com.vmware.nsx_policy.infra.LbPools; import com.vmware.nsx_policy.infra.LbServices; import com.vmware.nsx_policy.infra.LbVirtualServers; +import com.vmware.nsx_policy.infra.IpsecVpnDpdProfiles; +import com.vmware.nsx_policy.infra.IpsecVpnIkeProfiles; +import com.vmware.nsx_policy.infra.IpsecVpnTunnelProfiles; +import com.vmware.nsx_policy.infra.Tier1s; +import com.vmware.nsx_policy.infra.tier_1s.IpsecVpnServices; +import com.vmware.nsx_policy.infra.tier_1s.LocaleServices; +import com.vmware.nsx_policy.infra.tier_1s.StaticRoutes; +import com.vmware.nsx_policy.infra.tier_1s.nat.NatRules; import com.vmware.nsx_policy.infra.domains.Groups; +import com.vmware.nsx_policy.infra.tier_1s.ipsec_vpn_services.Sessions; import com.vmware.nsx_policy.model.ApiError; import com.vmware.nsx_policy.model.Group; +import com.vmware.nsx_policy.model.IPSecVpnDpdProfile; +import com.vmware.nsx_policy.model.IPSecVpnIkeProfile; +import com.vmware.nsx_policy.model.IPSecVpnServiceListResult; +import com.vmware.nsx_policy.model.IPSecVpnTunnelProfile; import com.vmware.nsx_policy.model.LBAppProfileListResult; import com.vmware.nsx_policy.model.LBIcmpMonitorProfile; import com.vmware.nsx_policy.model.LBService; @@ -38,6 +51,12 @@ import com.vmware.nsx_policy.model.LBPoolMember; import com.vmware.nsx_policy.model.LBVirtualServer; import com.vmware.nsx_policy.model.PathExpression; +import com.vmware.nsx_policy.model.PolicyNatRule; +import com.vmware.nsx_policy.model.PolicyNatRuleListResult; +import com.vmware.nsx_policy.model.RouteBasedIPSecVpnSession; +import com.vmware.nsx_policy.model.StaticRoutesListResult; +import com.vmware.nsx_policy.model.Tag; +import com.vmware.nsx_policy.model.Tier1; import com.vmware.vapi.bindings.Service; import com.vmware.vapi.bindings.Structure; import com.vmware.vapi.std.errors.Error; @@ -51,16 +70,21 @@ import org.mockito.MockedConstruction; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import java.util.List; import java.util.function.Function; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -391,6 +415,237 @@ public void testCreateNsxLbServerPoolThrowsExceptionOnPatchError() { assertTrue(thrownException.getMessage().startsWith("Failed to create NSX LB server pool, due to")); } + @Test + public void testVpnNatOrderingRestoresOriginalSourceNatSequenceAndTags() { + NatRules natRules = Mockito.mock(NatRules.class); + PolicyNatRule sourceNatRule = new PolicyNatRule.Builder() + .setId("t1-NAT") + .setDisplayName("CloudStack source NAT") + .setAction("SNAT") + .setTranslatedNetwork("203.0.113.10") + .setSourceNetwork("0.0.0.0/0") + .setDestinationNetwork("ANY") + .setSequenceNumber(42L) + .setTags(List.of(new Tag.Builder().setScope("owner").setTag("cloudstack").build())) + .setEnabled(true) + .build(); + Mockito.when(nsxService.apply(NatRules.class)).thenReturn(natRules); + Mockito.when(natRules.get(TIER_1_GATEWAY_NAME, "USER", "t1-NAT")).thenReturn(sourceNatRule); + + client.ensureVpnNatExemptions(TIER_1_GATEWAY_NAME, "203.0.113.20"); + + ArgumentCaptor demotedCaptor = ArgumentCaptor.forClass(PolicyNatRule.class); + verify(natRules).patch(eq(TIER_1_GATEWAY_NAME), eq("USER"), eq("t1-NAT"), demotedCaptor.capture()); + PolicyNatRule demotedRule = demotedCaptor.getValue(); + assertEquals(Long.valueOf(1000L), demotedRule.getSequenceNumber()); + assertEquals("203.0.113.10", demotedRule.getTranslatedNetwork()); + assertTrue(demotedRule.getTags().stream().anyMatch(tag -> "owner".equals(tag.getScope()) + && "cloudstack".equals(tag.getTag()))); + assertTrue(demotedRule.getTags().stream().anyMatch(tag -> "cloudstack-vpn-original-snat-sequence".equals(tag.getScope()) + && "42".equals(tag.getTag()))); + + clearInvocations(natRules); + Mockito.when(natRules.get(TIER_1_GATEWAY_NAME, "USER", "t1-NAT")).thenReturn(demotedRule); + client.restoreSourceNatRuleSequence(TIER_1_GATEWAY_NAME); + + ArgumentCaptor restoredCaptor = ArgumentCaptor.forClass(PolicyNatRule.class); + verify(natRules).patch(eq(TIER_1_GATEWAY_NAME), eq("USER"), eq("t1-NAT"), restoredCaptor.capture()); + PolicyNatRule restoredRule = restoredCaptor.getValue(); + assertEquals(Long.valueOf(42L), restoredRule.getSequenceNumber()); + assertEquals("203.0.113.10", restoredRule.getTranslatedNetwork()); + assertEquals(1, restoredRule.getTags().size()); + assertEquals("owner", restoredRule.getTags().get(0).getScope()); + assertEquals("cloudstack", restoredRule.getTags().get(0).getTag()); + } + + @Test + public void testDeleteTier1GatewayRemovesVpnResourcesBeforeLocaleServices() { + Tier1s tier1s = Mockito.mock(Tier1s.class); + StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); + NatRules natRules = Mockito.mock(NatRules.class); + IpsecVpnServices vpnServices = Mockito.mock(IpsecVpnServices.class); + LocaleServices localeServices = Mockito.mock(LocaleServices.class); + StaticRoutesListResult staticRoutesResult = Mockito.mock(StaticRoutesListResult.class); + PolicyNatRuleListResult natRulesResult = Mockito.mock(PolicyNatRuleListResult.class); + IPSecVpnServiceListResult vpnServicesResult = Mockito.mock(IPSecVpnServiceListResult.class); + + when(nsxService.apply(Tier1s.class)).thenReturn(tier1s); + when(nsxService.apply(StaticRoutes.class)).thenReturn(staticRoutes); + when(nsxService.apply(NatRules.class)).thenReturn(natRules); + when(nsxService.apply(IpsecVpnServices.class)).thenReturn(vpnServices); + when(nsxService.apply(LocaleServices.class)).thenReturn(localeServices); + when(tier1s.get(TIER_1_GATEWAY_NAME)).thenReturn(Mockito.mock(Tier1.class)); + when(staticRoutes.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) + .thenReturn(staticRoutesResult); + when(staticRoutesResult.getResults()).thenReturn(List.of()); + when(natRules.list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) + .thenReturn(natRulesResult); + when(natRulesResult.getResults()).thenReturn(List.of()); + when(vpnServices.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), eq(false), nullable(String.class))) + .thenReturn(vpnServicesResult); + when(vpnServicesResult.getResults()).thenReturn(List.of()); + + client.deleteTier1Gateway(TIER_1_GATEWAY_NAME); + + InOrder inOrder = Mockito.inOrder(staticRoutes, natRules, vpnServices, localeServices, tier1s); + inOrder.verify(staticRoutes).list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); + inOrder.verify(natRules).list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); + inOrder.verify(natRules).delete(eq(TIER_1_GATEWAY_NAME), anyString(), anyString()); + inOrder.verify(vpnServices).list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), eq(false), nullable(String.class)); + inOrder.verify(natRules).list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); + inOrder.verify(localeServices).delete(TIER_1_GATEWAY_NAME, "default"); + inOrder.verify(tier1s).delete(TIER_1_GATEWAY_NAME); + } + + @Test + public void testCreateRouteBasedVpnSessionRemovesSessionWhenPatchFails() { + IpsecVpnIkeProfiles ikeProfiles = Mockito.mock(IpsecVpnIkeProfiles.class); + IpsecVpnTunnelProfiles tunnelProfiles = Mockito.mock(IpsecVpnTunnelProfiles.class); + IpsecVpnDpdProfiles dpdProfiles = Mockito.mock(IpsecVpnDpdProfiles.class); + Sessions sessions = Mockito.mock(Sessions.class); + Structure errorData = Mockito.mock(Structure.class); + ApiError apiError = new ApiError(); + apiError.setErrorData(errorData); + + Mockito.when(nsxService.apply(IpsecVpnIkeProfiles.class)).thenReturn(ikeProfiles); + Mockito.when(nsxService.apply(IpsecVpnTunnelProfiles.class)).thenReturn(tunnelProfiles); + Mockito.when(nsxService.apply(IpsecVpnDpdProfiles.class)).thenReturn(dpdProfiles); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(errorData._convertTo(ApiError.class)).thenReturn(apiError); + doThrow(new Error(List.of(), errorData)).when(sessions).patch(anyString(), anyString(), anyString(), any(RouteBasedIPSecVpnSession.class)); + + assertThrows(CloudRuntimeException.class, () -> client.createRouteBasedVpnSession( + TIER_1_GATEWAY_NAME, "connection-uuid", "203.0.113.10", "psk", + "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, + true, "ikev2", false, "169.254.64.21", 30)); + + verify(sessions).delete(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), eq("cs-conn-connection-uuid")); + verify(ikeProfiles).delete("cs-conn-connection-uuid-ike"); + verify(tunnelProfiles).delete("cs-conn-connection-uuid-esp"); + verify(dpdProfiles).delete("cs-conn-connection-uuid-dpd"); + } + + @Test + public void testCreateRouteBasedVpnSessionDoesNotDeleteExistingSessionWhenPatchFails() { + IpsecVpnIkeProfiles ikeProfiles = Mockito.mock(IpsecVpnIkeProfiles.class); + IpsecVpnTunnelProfiles tunnelProfiles = Mockito.mock(IpsecVpnTunnelProfiles.class); + IpsecVpnDpdProfiles dpdProfiles = Mockito.mock(IpsecVpnDpdProfiles.class); + Sessions sessions = Mockito.mock(Sessions.class); + Structure existingSession = Mockito.mock(Structure.class); + Structure errorData = Mockito.mock(Structure.class); + ApiError apiError = new ApiError(); + apiError.setErrorData(errorData); + + Mockito.when(nsxService.apply(IpsecVpnIkeProfiles.class)).thenReturn(ikeProfiles); + Mockito.when(nsxService.apply(IpsecVpnTunnelProfiles.class)).thenReturn(tunnelProfiles); + Mockito.when(nsxService.apply(IpsecVpnDpdProfiles.class)).thenReturn(dpdProfiles); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(sessions.get(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + .thenReturn(existingSession); + Mockito.when(errorData._convertTo(ApiError.class)).thenReturn(apiError); + doThrow(new Error(List.of(), errorData)).when(sessions).patch(anyString(), anyString(), anyString(), any(RouteBasedIPSecVpnSession.class)); + + assertThrows(CloudRuntimeException.class, () -> client.createRouteBasedVpnSession( + TIER_1_GATEWAY_NAME, "connection-uuid", "203.0.113.10", "psk", + "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, + true, "ikev2", false, "169.254.64.21", 30)); + + verify(sessions, never()).delete(anyString(), anyString(), anyString()); + verify(ikeProfiles, never()).delete(anyString()); + verify(tunnelProfiles, never()).delete(anyString()); + verify(dpdProfiles, never()).delete(anyString()); + } + + @Test + public void testCreateRouteBasedVpnSessionCleansProfilesWhenProfilePatchFails() { + IpsecVpnIkeProfiles ikeProfiles = Mockito.mock(IpsecVpnIkeProfiles.class); + IpsecVpnTunnelProfiles tunnelProfiles = Mockito.mock(IpsecVpnTunnelProfiles.class); + IpsecVpnDpdProfiles dpdProfiles = Mockito.mock(IpsecVpnDpdProfiles.class); + Sessions sessions = Mockito.mock(Sessions.class); + Structure errorData = Mockito.mock(Structure.class); + ApiError apiError = new ApiError(); + apiError.setErrorData(errorData); + + Mockito.when(nsxService.apply(IpsecVpnIkeProfiles.class)).thenReturn(ikeProfiles); + Mockito.when(nsxService.apply(IpsecVpnTunnelProfiles.class)).thenReturn(tunnelProfiles); + Mockito.when(nsxService.apply(IpsecVpnDpdProfiles.class)).thenReturn(dpdProfiles); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(errorData._convertTo(ApiError.class)).thenReturn(apiError); + doThrow(new Error(List.of(), errorData)).when(tunnelProfiles) + .patch(anyString(), any(IPSecVpnTunnelProfile.class)); + + assertThrows(CloudRuntimeException.class, () -> client.createRouteBasedVpnSession( + TIER_1_GATEWAY_NAME, "connection-uuid", "203.0.113.10", "psk", + "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, + true, "ikev2", false, "169.254.64.21", 30)); + + verify(sessions).delete(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid"); + verify(ikeProfiles).delete("cs-conn-connection-uuid-ike"); + verify(tunnelProfiles).delete("cs-conn-connection-uuid-esp"); + verify(dpdProfiles).delete("cs-conn-connection-uuid-dpd"); + } + + @Test + public void testCreateRouteBasedVpnSessionPreservesPreExistingProfilesAfterFailure() { + IpsecVpnIkeProfiles ikeProfiles = Mockito.mock(IpsecVpnIkeProfiles.class); + IpsecVpnTunnelProfiles tunnelProfiles = Mockito.mock(IpsecVpnTunnelProfiles.class); + IpsecVpnDpdProfiles dpdProfiles = Mockito.mock(IpsecVpnDpdProfiles.class); + Sessions sessions = Mockito.mock(Sessions.class); + Structure errorData = Mockito.mock(Structure.class); + ApiError apiError = new ApiError(); + apiError.setErrorData(errorData); + + Mockito.when(nsxService.apply(IpsecVpnIkeProfiles.class)).thenReturn(ikeProfiles); + Mockito.when(nsxService.apply(IpsecVpnTunnelProfiles.class)).thenReturn(tunnelProfiles); + Mockito.when(nsxService.apply(IpsecVpnDpdProfiles.class)).thenReturn(dpdProfiles); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(ikeProfiles.get("cs-conn-connection-uuid-ike")).thenReturn(Mockito.mock(IPSecVpnIkeProfile.class)); + Mockito.when(tunnelProfiles.get("cs-conn-connection-uuid-esp")).thenReturn(Mockito.mock(IPSecVpnTunnelProfile.class)); + Mockito.when(dpdProfiles.get("cs-conn-connection-uuid-dpd")).thenReturn(Mockito.mock(IPSecVpnDpdProfile.class)); + Mockito.when(errorData._convertTo(ApiError.class)).thenReturn(apiError); + doThrow(new Error(List.of(), errorData)).when(sessions) + .patch(anyString(), anyString(), anyString(), any(RouteBasedIPSecVpnSession.class)); + + assertThrows(CloudRuntimeException.class, () -> client.createRouteBasedVpnSession( + TIER_1_GATEWAY_NAME, "connection-uuid", "203.0.113.10", "psk", + "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, + true, "ikev2", false, "169.254.64.21", 30)); + + verify(ikeProfiles, never()).delete(anyString()); + verify(tunnelProfiles, never()).delete(anyString()); + verify(dpdProfiles, never()).delete(anyString()); + } + + @Test + public void testDeleteVpnConnectionContinuesCleanupAfterRouteAndNatFailures() { + IpsecVpnIkeProfiles ikeProfiles = Mockito.mock(IpsecVpnIkeProfiles.class); + IpsecVpnTunnelProfiles tunnelProfiles = Mockito.mock(IpsecVpnTunnelProfiles.class); + IpsecVpnDpdProfiles dpdProfiles = Mockito.mock(IpsecVpnDpdProfiles.class); + Sessions sessions = Mockito.mock(Sessions.class); + Mockito.when(nsxService.apply(com.vmware.nsx_policy.infra.tier_1s.StaticRoutes.class)) + .thenThrow(new CloudRuntimeException("route cleanup failed")); + Mockito.when(nsxService.apply(NatRules.class)).thenThrow(new CloudRuntimeException("NAT cleanup failed")); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(nsxService.apply(IpsecVpnIkeProfiles.class)).thenReturn(ikeProfiles); + Mockito.when(nsxService.apply(IpsecVpnTunnelProfiles.class)).thenReturn(tunnelProfiles); + Mockito.when(nsxService.apply(IpsecVpnDpdProfiles.class)).thenReturn(dpdProfiles); + + assertThrows(CloudRuntimeException.class, + () -> client.deleteVpnConnection(TIER_1_GATEWAY_NAME, "connection-uuid")); + + verify(sessions).delete(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid"); + verify(ikeProfiles).delete("cs-conn-connection-uuid-ike"); + verify(tunnelProfiles).delete("cs-conn-connection-uuid-esp"); + verify(dpdProfiles).delete("cs-conn-connection-uuid-dpd"); + } + private LbMonitorProfiles mockLbMonitorProfiles() { LbMonitorProfiles lbMonitorProfiles = Mockito.mock(LbMonitorProfiles.class); Structure monitorStructure = Mockito.mock(Structure.class, Mockito.RETURNS_DEEP_STUBS); diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java index a807155a2dc1..0f74b215ac48 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java @@ -23,36 +23,53 @@ import com.cloud.domain.DomainVO; import com.cloud.domain.dao.DomainDao; import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.ResourceUnavailableException; import com.cloud.hypervisor.Hypervisor; +import com.cloud.network.IpAddress; +import com.cloud.network.IpAddressManager; import com.cloud.network.Network; import com.cloud.network.NetworkModel; import com.cloud.network.Networks; +import com.cloud.network.Site2SiteVpnConnection; +import com.cloud.network.Site2SiteVpnGateway; +import com.cloud.network.dao.FirewallRulesDao; import com.cloud.network.dao.IPAddressDao; import com.cloud.network.dao.IPAddressVO; import com.cloud.network.dao.LoadBalancerVMMapDao; +import com.cloud.network.dao.LoadBalancerDao; import com.cloud.network.dao.LoadBalancerVO; import com.cloud.network.dao.NetworkDao; import com.cloud.network.dao.NetworkVO; import com.cloud.network.dao.PhysicalNetworkDao; import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.dao.Site2SiteCustomerGatewayDao; +import com.cloud.network.dao.Site2SiteCustomerGatewayVO; +import com.cloud.network.dao.Site2SiteVpnGatewayDao; +import com.cloud.network.dao.Site2SiteVpnGatewayVO; import com.cloud.network.element.PortForwardingServiceProvider; import com.cloud.network.lb.LoadBalancingRule; +import com.cloud.network.nsx.NsxVpnGatewayResult; import com.cloud.network.rules.FirewallRule; import com.cloud.network.rules.FirewallRuleVO; import com.cloud.network.rules.PortForwardingRule; import com.cloud.network.rules.PortForwardingRuleVO; import com.cloud.network.rules.StaticNatImpl; +import com.cloud.network.rules.dao.PortForwardingRulesDao; import com.cloud.network.vpc.NetworkACLItem; import com.cloud.network.vpc.NetworkACLItemVO; import com.cloud.network.vpc.Vpc; +import com.cloud.network.vpc.VpcOfferingServiceMapVO; +import com.cloud.network.vpc.VpcService; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; import com.cloud.network.vpc.dao.VpcOfferingServiceMapDao; import com.cloud.resource.ResourceManager; import com.cloud.user.Account; import com.cloud.user.AccountManager; +import com.cloud.user.User; import com.cloud.utils.Pair; +import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.net.Ip; import com.cloud.vm.NicVO; import com.cloud.vm.ReservationContext; @@ -61,8 +78,11 @@ import com.cloud.vm.dao.UserVmDao; import com.cloud.vm.dao.VMInstanceDao; import org.apache.cloudstack.acl.ControlledEntity; +import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.resource.NsxNetworkRule; +import org.apache.cloudstack.resourcedetail.UserIpAddressDetailVO; import org.apache.cloudstack.resourcedetail.dao.FirewallRuleDetailsDao; +import org.apache.cloudstack.resourcedetail.dao.UserIpAddressDetailsDao; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -80,10 +100,15 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -126,7 +151,23 @@ public class NsxElementTest { @Mock LoadBalancerVMMapDao lbVmMapDao; @Mock + LoadBalancerDao loadBalancerDao; + @Mock FirewallRuleDetailsDao firewallRuleDetailsDao; + @Mock + IpAddressManager ipAddressManager; + @Mock + VpcService vpcService; + @Mock + Site2SiteVpnGatewayDao vpnGatewayDao; + @Mock + Site2SiteCustomerGatewayDao customerGatewayDao; + @Mock + UserIpAddressDetailsDao userIpAddressDetailsDao; + @Mock + FirewallRulesDao firewallRulesDao; + @Mock + PortForwardingRulesDao portForwardingRulesDao; NsxElement nsxElement; ReservationContext reservationContext; @@ -151,7 +192,19 @@ public void setup() throws NoSuchFieldException, IllegalAccessException { nsxElement.vmInstanceDao = vmInstanceDao; nsxElement.vpcDao = vpcDao; nsxElement.lbVmMapDao = lbVmMapDao; + nsxElement.loadBalancerDao = loadBalancerDao; nsxElement.firewallRuleDetailsDao = firewallRuleDetailsDao; + nsxElement.ipAddressManager = ipAddressManager; + nsxElement.vpcService = vpcService; + nsxElement.vpnGatewayDao = vpnGatewayDao; + nsxElement.customerGatewayDao = customerGatewayDao; + nsxElement.userIpAddressDetailsDao = userIpAddressDetailsDao; + nsxElement.firewallRulesDao = firewallRulesDao; + nsxElement.portForwardingRulesDao = portForwardingRulesDao; + Mockito.lenient().when(ipAddressManager.disassociatePublicIpAddress(any(), anyLong(), any())).thenReturn(true); + Mockito.lenient().when(loadBalancerDao.listByIpAddress(anyLong())).thenReturn(List.of()); + Mockito.lenient().when(firewallRulesDao.listByIpAndNotRevoked(anyLong())).thenReturn(List.of()); + Mockito.lenient().when(portForwardingRulesDao.listByIpAndNotRevoked(anyLong())).thenReturn(List.of()); Field field = ApiDBUtils.class.getDeclaredField("s_ipAddressDao"); field.setAccessible(true); @@ -470,4 +523,454 @@ public void testRevokeFirewallRules() throws ResourceUnavailableException { when(nsxService.addFirewallRules(any(Network.class), any(List.class))).thenReturn(true); assertTrue(nsxElement.applyFWRules(networkVO, List.of(rule))); } + + private VpcVO mockVpcWithNsxVpnSupport() { + VpcVO vpcVO = Mockito.mock(VpcVO.class); + Mockito.lenient().when(vpcVO.getId()).thenReturn(9L); + when(vpcVO.getVpcOfferingId()).thenReturn(11L); + when(vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId(Network.Service.Vpn.getName(), + Network.Provider.Nsx.getName(), 11L)).thenReturn(Mockito.mock(VpcOfferingServiceMapVO.class)); + return vpcVO; + } + + private IPAddressVO mockIpAddressVO(long id, String address) { + IPAddressVO ipAddressVO = Mockito.mock(IPAddressVO.class); + when(ipAddressDao.findById(id)).thenReturn(ipAddressVO); + Mockito.lenient().when(ipAddressVO.getAddress()).thenReturn(new Ip(address)); + Mockito.lenient().when(ipAddressVO.readyToUse()).thenReturn(true); + Mockito.lenient().when(ipAddressVO.getRemoved()).thenReturn(null); + return ipAddressVO; + } + + @Test + public void testAcquireVpnGatewayIpWithRequestedIp() { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + IpAddress requestedIp = Mockito.mock(IpAddress.class); + when(requestedIp.getId()).thenReturn(20L); + IPAddressVO ipAddressVO = mockIpAddressVO(20L, "10.1.13.20"); + when(ipAddressVO.getId()).thenReturn(20L); + when(ipAddressVO.getVpcId()).thenReturn(9L); + when(loadBalancerDao.listByIpAddress(20L)).thenReturn(List.of()); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.20")).thenReturn(new NsxVpnGatewayResult(true, true)); + + IpAddress result = nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp); + assertEquals(ipAddressVO, result); + verify(userIpAddressDetailsDao).addDetail(20L, "nsxVpnGatewayIp", "false", false); + } + + @Test + public void testAcquireVpnGatewayIpDoesNotCallNsxWhenRequestedIpOwnershipCannotBeRecorded() { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + IpAddress requestedIp = Mockito.mock(IpAddress.class); + when(requestedIp.getId()).thenReturn(20L); + IPAddressVO ipAddressVO = mockIpAddressVO(20L, "10.1.13.20"); + when(ipAddressVO.getId()).thenReturn(20L); + when(ipAddressVO.getVpcId()).thenReturn(9L); + when(loadBalancerDao.listByIpAddress(20L)).thenReturn(List.of()); + Mockito.doThrow(new CloudRuntimeException("marker write failed")).when(userIpAddressDetailsDao) + .addDetail(20L, "nsxVpnGatewayIp", "false", false); + + Assert.assertThrows(CloudRuntimeException.class, + () -> nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp)); + + verify(nsxService, never()).createVpnGateway(any(Vpc.class), anyString()); + } + + @Test + public void testAcquireVpnGatewayIpRemovesRequestedIpOwnershipWhenNsxRejectsGateway() { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + IpAddress requestedIp = Mockito.mock(IpAddress.class); + when(requestedIp.getId()).thenReturn(20L); + IPAddressVO ipAddressVO = mockIpAddressVO(20L, "10.1.13.20"); + when(ipAddressVO.getId()).thenReturn(20L); + when(ipAddressVO.getVpcId()).thenReturn(9L); + when(loadBalancerDao.listByIpAddress(20L)).thenReturn(List.of()); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.20")).thenReturn(new NsxVpnGatewayResult(false, false)); + + Assert.assertThrows(CloudRuntimeException.class, + () -> nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp)); + + verify(userIpAddressDetailsDao).removeDetail(20L, "nsxVpnGatewayIp"); + } + + @Test + public void testAcquireVpnGatewayIpRetainsRequestedIpOwnershipWhenNsxResultIsAmbiguous() { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + IpAddress requestedIp = Mockito.mock(IpAddress.class); + when(requestedIp.getId()).thenReturn(20L); + IPAddressVO ipAddressVO = mockIpAddressVO(20L, "10.1.13.20"); + when(ipAddressVO.getId()).thenReturn(20L); + when(ipAddressVO.getVpcId()).thenReturn(9L); + when(loadBalancerDao.listByIpAddress(20L)).thenReturn(List.of()); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.20")).thenReturn(new NsxVpnGatewayResult(false, true)); + + Assert.assertThrows(CloudRuntimeException.class, + () -> nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp)); + + verify(userIpAddressDetailsDao, never()).removeDetail(20L, "nsxVpnGatewayIp"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testAcquireVpnGatewayIpRejectsSourceNatIp() { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + IpAddress requestedIp = Mockito.mock(IpAddress.class); + when(requestedIp.getId()).thenReturn(20L); + IPAddressVO ipAddressVO = Mockito.mock(IPAddressVO.class); + when(ipAddressDao.findById(20L)).thenReturn(ipAddressVO); + when(ipAddressVO.getVpcId()).thenReturn(9L); + when(ipAddressVO.readyToUse()).thenReturn(true); + when(ipAddressVO.getRemoved()).thenReturn(null); + when(ipAddressVO.isSourceNat()).thenReturn(true); + when(ipAddressVO.getAddress()).thenReturn(new Ip("10.1.13.20")); + + nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp); + } + + @Test(expected = InvalidParameterValueException.class) + public void testAcquireVpnGatewayIpRejectsIpWithPortForwardingRule() { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + IpAddress requestedIp = Mockito.mock(IpAddress.class); + when(requestedIp.getId()).thenReturn(20L); + IPAddressVO ipAddressVO = mockIpAddressVO(20L, "10.1.13.20"); + when(ipAddressVO.getId()).thenReturn(20L); + when(ipAddressVO.getVpcId()).thenReturn(9L); + when(portForwardingRulesDao.listByIpAndNotRevoked(20L)) + .thenReturn(List.of(Mockito.mock(PortForwardingRuleVO.class))); + + nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp); + } + + @Test(expected = InvalidParameterValueException.class) + public void testAcquireVpnGatewayIpRejectsAnUnallocatedIp() { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + IpAddress requestedIp = Mockito.mock(IpAddress.class); + when(requestedIp.getId()).thenReturn(21L); + IPAddressVO ipAddressVO = Mockito.mock(IPAddressVO.class); + when(ipAddressDao.findById(21L)).thenReturn(ipAddressVO); + when(ipAddressVO.getVpcId()).thenReturn(9L); + when(ipAddressVO.readyToUse()).thenReturn(false); + + nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp); + } + + @Test + public void testAcquireVpnGatewayIpReturnsNullWhenVpnIsNotProvidedByNsx() { + VpcVO vpcVO = Mockito.mock(VpcVO.class); + when(vpcVO.getVpcOfferingId()).thenReturn(11L); + + assertNull(nsxElement.acquireVpnGatewayIp(vpcVO, null)); + } + + @Test + public void testAcquireVpnGatewayIpAutoAcquiresWhenNoIpIsRequested() throws Exception { + CallContext.register(Mockito.mock(User.class), Mockito.mock(Account.class)); + try { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + when(vpcVO.getAccountId()).thenReturn(2L); + when(vpcVO.getZoneId()).thenReturn(1L); + IpAddress allocatedIp = Mockito.mock(IpAddress.class); + when(allocatedIp.getId()).thenReturn(30L); + when(ipAddressManager.allocateIp(any(), anyBoolean(), any(), any(), any(), any(), any())).thenReturn(allocatedIp); + IPAddressVO ipAddressVO = mockIpAddressVO(30L, "10.1.13.30"); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.30")).thenReturn(new NsxVpnGatewayResult(true, true)); + + IpAddress result = nsxElement.acquireVpnGatewayIp(vpcVO, null); + assertEquals(ipAddressVO, result); + verify(vpcService).associateIPToVpc(30L, 9L); + verify(userIpAddressDetailsDao).addDetail(30L, "nsxVpnGatewayIp", "true", false); + } finally { + CallContext.unregister(); + } + } + + @Test(expected = CloudRuntimeException.class) + public void testAcquireVpnGatewayIpReleasesAutoAcquiredIpWhenNsxRejectsGateway() throws Exception { + CallContext.register(Mockito.mock(User.class), Mockito.mock(Account.class)); + try { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + when(vpcVO.getAccountId()).thenReturn(2L); + when(vpcVO.getZoneId()).thenReturn(1L); + IpAddress allocatedIp = Mockito.mock(IpAddress.class); + when(allocatedIp.getId()).thenReturn(31L); + when(ipAddressManager.allocateIp(any(), anyBoolean(), any(), any(), any(), any(), any())).thenReturn(allocatedIp); + IPAddressVO ipAddressVO = mockIpAddressVO(31L, "10.1.13.31"); + when(ipAddressVO.getId()).thenReturn(31L); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.31")).thenReturn(new NsxVpnGatewayResult(false, false)); + when(ipAddressManager.disassociatePublicIpAddress(any(IPAddressVO.class), anyLong(), any())).thenReturn(true); + + nsxElement.acquireVpnGatewayIp(vpcVO, null); + } finally { + verify(userIpAddressDetailsDao).removeDetail(31L, "nsxVpnGatewayIp"); + verify(ipAddressManager).disassociatePublicIpAddress(any(IPAddressVO.class), anyLong(), any()); + CallContext.unregister(); + } + } + + @Test(expected = CloudRuntimeException.class) + public void testAcquireVpnGatewayIpRetainsAutoAcquiredIpWhenEndpointMayBeInUse() throws Exception { + CallContext.register(Mockito.mock(User.class), Mockito.mock(Account.class)); + try { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + when(vpcVO.getAccountId()).thenReturn(2L); + when(vpcVO.getZoneId()).thenReturn(1L); + IpAddress allocatedIp = Mockito.mock(IpAddress.class); + when(allocatedIp.getId()).thenReturn(32L); + when(ipAddressManager.allocateIp(any(), anyBoolean(), any(), any(), any(), any(), any())).thenReturn(allocatedIp); + mockIpAddressVO(32L, "10.1.13.32"); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.32")).thenReturn(new NsxVpnGatewayResult(false, true)); + + nsxElement.acquireVpnGatewayIp(vpcVO, null); + } finally { + verify(userIpAddressDetailsDao, never()).removeDetail(32L, "nsxVpnGatewayIp"); + verify(ipAddressManager, never()).disassociatePublicIpAddress(any(IPAddressVO.class), anyLong(), any()); + CallContext.unregister(); + } + } + + @Test + public void testReleaseVpnGatewayIpReleasesAutoAcquiredIp() { + CallContext.register(Mockito.mock(User.class), Mockito.mock(Account.class)); + try { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + when(vpcDao.findById(9L)).thenReturn(vpcVO); + Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); + when(vpnGateway.getVpcId()).thenReturn(9L); + when(vpnGateway.getAddrId()).thenReturn(30L); + when(nsxService.deleteVpnGateway(vpcVO)).thenReturn(true); + IPAddressVO ipAddressVO = mockIpAddressVO(30L, "10.1.13.30"); + when(ipAddressVO.getId()).thenReturn(30L); + UserIpAddressDetailVO detail = Mockito.mock(UserIpAddressDetailVO.class); + when(detail.getValue()).thenReturn("true"); + when(userIpAddressDetailsDao.findDetail(30L, "nsxVpnGatewayIp")).thenReturn(detail); + + nsxElement.releaseVpnGatewayIp(vpnGateway); + verify(userIpAddressDetailsDao).removeDetail(30L, "nsxVpnGatewayIp"); + verify(ipAddressManager).disassociatePublicIpAddress(eq(ipAddressVO), anyLong(), any()); + } finally { + CallContext.unregister(); + } + } + + @Test + public void testReleaseVpnGatewayIpKeepsOperatorSpecifiedIp() { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + when(vpcDao.findById(9L)).thenReturn(vpcVO); + Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); + when(vpnGateway.getVpcId()).thenReturn(9L); + when(vpnGateway.getAddrId()).thenReturn(30L); + when(nsxService.deleteVpnGateway(vpcVO)).thenReturn(true); + IPAddressVO ipAddressVO = mockIpAddressVO(30L, "10.1.13.30"); + when(ipAddressVO.getId()).thenReturn(30L); + when(userIpAddressDetailsDao.findDetail(30L, "nsxVpnGatewayIp")).thenReturn(null); + + nsxElement.releaseVpnGatewayIp(vpnGateway); + verify(ipAddressManager, Mockito.never()).disassociatePublicIpAddress(any(), anyLong(), any()); + } + + @Test + public void testNsxVpnGatewayOwnershipIsRecordedForOperatorSpecifiedIp() { + Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); + when(vpnGateway.getAddrId()).thenReturn(30L); + when(userIpAddressDetailsDao.findDetail(30L, "nsxVpnGatewayIp")) + .thenReturn(Mockito.mock(UserIpAddressDetailVO.class)); + + assertTrue(nsxElement.ownsVpnGateway(vpnGateway)); + } + + @Test + public void testReleaseVpnGatewayIpUsesPersistedOwnershipWhenOfferingMappingIsGone() { + VpcVO vpcVO = Mockito.mock(VpcVO.class); + when(vpcDao.findById(9L)).thenReturn(vpcVO); + Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); + when(vpnGateway.getVpcId()).thenReturn(9L); + when(vpnGateway.getAddrId()).thenReturn(30L); + when(nsxService.deleteVpnGateway(vpcVO)).thenReturn(true); + IPAddressVO ipAddressVO = mockIpAddressVO(30L, "10.1.13.30"); + when(ipAddressVO.getId()).thenReturn(30L); + UserIpAddressDetailVO detail = Mockito.mock(UserIpAddressDetailVO.class); + when(detail.getValue()).thenReturn("false"); + when(userIpAddressDetailsDao.findDetail(30L, "nsxVpnGatewayIp")).thenReturn(detail); + + nsxElement.releaseVpnGatewayIp(vpnGateway); + + verify(nsxService).deleteVpnGateway(vpcVO); + verify(userIpAddressDetailsDao).removeDetail(30L, "nsxVpnGatewayIp"); + verify(ipAddressManager, Mockito.never()).disassociatePublicIpAddress(any(), anyLong(), any()); + } + + @Test + public void testReleaseVpnGatewayIpRemovesOperatorMarkerWhenVpcRowIsGone() { + when(vpcDao.findById(9L)).thenReturn(null); + Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); + when(vpnGateway.getVpcId()).thenReturn(9L); + when(vpnGateway.getAddrId()).thenReturn(30L); + IPAddressVO ipAddressVO = mockIpAddressVO(30L, "10.1.13.30"); + when(ipAddressVO.getId()).thenReturn(30L); + UserIpAddressDetailVO detail = Mockito.mock(UserIpAddressDetailVO.class); + when(detail.getValue()).thenReturn("false"); + when(userIpAddressDetailsDao.findDetail(30L, "nsxVpnGatewayIp")).thenReturn(detail); + + nsxElement.releaseVpnGatewayIp(vpnGateway); + + verify(nsxService, Mockito.never()).deleteVpnGateway(any(Vpc.class)); + verify(userIpAddressDetailsDao).removeDetail(30L, "nsxVpnGatewayIp"); + verify(ipAddressManager, Mockito.never()).disassociatePublicIpAddress(any(), anyLong(), any()); + } + + @Test(expected = CloudRuntimeException.class) + public void testReleaseVpnGatewayIpDoesNotReleaseIpWhenNsxRejectsDeletion() { + CallContext.register(Mockito.mock(User.class), Mockito.mock(Account.class)); + try { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + when(vpcDao.findById(9L)).thenReturn(vpcVO); + Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); + when(vpnGateway.getVpcId()).thenReturn(9L); + when(nsxService.deleteVpnGateway(vpcVO)).thenReturn(false); + + nsxElement.releaseVpnGatewayIp(vpnGateway); + } finally { + verify(ipAddressManager, Mockito.never()).disassociatePublicIpAddress(any(), anyLong(), any()); + CallContext.unregister(); + } + } + + @Test(expected = CloudRuntimeException.class) + public void testReleaseVpnGatewayIpKeepsTheMarkerWhenIpDisassociationFails() { + CallContext.register(Mockito.mock(User.class), Mockito.mock(Account.class)); + try { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + when(vpcDao.findById(9L)).thenReturn(vpcVO); + Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); + when(vpnGateway.getVpcId()).thenReturn(9L); + when(vpnGateway.getAddrId()).thenReturn(30L); + when(nsxService.deleteVpnGateway(vpcVO)).thenReturn(true); + IPAddressVO ipAddressVO = mockIpAddressVO(30L, "10.1.13.30"); + when(ipAddressVO.getId()).thenReturn(30L); + UserIpAddressDetailVO detail = Mockito.mock(UserIpAddressDetailVO.class); + when(detail.getValue()).thenReturn("true"); + when(userIpAddressDetailsDao.findDetail(30L, "nsxVpnGatewayIp")).thenReturn(detail); + when(ipAddressManager.disassociatePublicIpAddress(any(), anyLong(), any())).thenReturn(false); + + nsxElement.releaseVpnGatewayIp(vpnGateway); + } finally { + verify(userIpAddressDetailsDao, Mockito.never()).removeDetail(30L, "nsxVpnGatewayIp"); + CallContext.unregister(); + } + } + + private Site2SiteCustomerGatewayVO mockCustomerGateway(String ikePolicy, String espPolicy) { + Site2SiteCustomerGatewayVO customerGateway = Mockito.mock(Site2SiteCustomerGatewayVO.class); + when(customerGatewayDao.findById(3L)).thenReturn(customerGateway); + when(customerGateway.getIkePolicy()).thenReturn(ikePolicy); + when(customerGateway.getEspPolicy()).thenReturn(espPolicy); + when(customerGateway.getIkeVersion()).thenReturn("ikev2"); + when(customerGateway.getIkeLifetime()).thenReturn(86400L); + when(customerGateway.getEspLifetime()).thenReturn(3600L); + when(customerGateway.getIpsecPsk()).thenReturn("presharedkey"); + return customerGateway; + } + + private Site2SiteVpnConnection mockVpnConnection(VpcVO vpcVO) { + Site2SiteVpnConnection connection = Mockito.mock(Site2SiteVpnConnection.class); + when(connection.getVpnGatewayId()).thenReturn(7L); + Mockito.lenient().when(connection.getCustomerGatewayId()).thenReturn(3L); + Site2SiteVpnGatewayVO vpnGateway = Mockito.mock(Site2SiteVpnGatewayVO.class); + when(vpnGatewayDao.findById(7L)).thenReturn(vpnGateway); + when(vpnGateway.getVpcId()).thenReturn(9L); + when(vpcDao.findById(9L)).thenReturn(vpcVO); + return connection; + } + + @Test + public void testStartSite2SiteVpn() throws ResourceUnavailableException { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + Site2SiteVpnConnection connection = mockVpnConnection(vpcVO); + when(connection.getId()).thenReturn(5L); + Site2SiteVpnGatewayVO vpnGateway = vpnGatewayDao.findById(7L); + when(vpnGateway.getAddrId()).thenReturn(30L); + mockIpAddressVO(30L, "10.1.13.30"); + Site2SiteCustomerGatewayVO customerGateway = mockCustomerGateway("aes256-sha256;modp2048", "aes128-sha1"); + when(customerGateway.getGatewayIp()).thenReturn("203.0.113.10"); + when(customerGateway.getGuestCidrList()).thenReturn("192.168.100.0/24,192.168.200.0/24"); + when(nsxService.createVpnConnection(any(Vpc.class), any(), anyString(), anyString(), anyString(), anyString(), + anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyList(), + eq("169.254.64.21"), eq("169.254.64.22"), anyInt(), eq("10.1.13.30"))).thenReturn(true); + + assertTrue(nsxElement.startSite2SiteVpn(connection)); + } + + @Test(expected = InvalidParameterValueException.class) + public void testStartSite2SiteVpnRejectsUnsupportedCrypto() throws ResourceUnavailableException { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + Site2SiteVpnConnection connection = mockVpnConnection(vpcVO); + mockCustomerGateway("3des-md5;modp1024", "3des-md5"); + + nsxElement.startSite2SiteVpn(connection); + } + + @Test(expected = InvalidParameterValueException.class) + public void testStartSite2SiteVpnRejectsDnsPeerAddress() throws ResourceUnavailableException { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + Site2SiteVpnConnection connection = mockVpnConnection(vpcVO); + Site2SiteCustomerGatewayVO customerGateway = mockCustomerGateway("aes256-sha256;modp2048", "aes128-sha1"); + when(customerGateway.getName()).thenReturn("remote-site"); + when(customerGateway.getGatewayIp()).thenReturn("vpn.example.test"); + + nsxElement.startSite2SiteVpn(connection); + } + + @Test + public void testStartSite2SiteVpnIsNoOpWhenVpnIsNotProvidedByNsx() throws ResourceUnavailableException { + VpcVO vpcVO = Mockito.mock(VpcVO.class); + when(vpcVO.getVpcOfferingId()).thenReturn(11L); + Site2SiteVpnConnection connection = mockVpnConnection(vpcVO); + + assertTrue(nsxElement.startSite2SiteVpn(connection)); + verify(nsxService, Mockito.never()).createVpnConnection(any(Vpc.class), any(), anyString(), anyString(), + anyString(), anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyList(), + anyString(), anyString(), anyInt(), anyString()); + } + + @Test(expected = CloudRuntimeException.class) + public void testStartSite2SiteVpnThrowsWhenVpcIsMissing() throws ResourceUnavailableException { + Site2SiteVpnConnection connection = mockVpnConnection(null); + + nsxElement.startSite2SiteVpn(connection); + } + + @Test + public void testStopSite2SiteVpn() throws ResourceUnavailableException { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + Site2SiteVpnConnection connection = mockVpnConnection(vpcVO); + when(connection.getUuid()).thenReturn("conn-uuid"); + when(nsxService.updateVpnConnectionState(vpcVO, "conn-uuid", false)).thenReturn(true); + + assertTrue(nsxElement.stopSite2SiteVpn(connection)); + } + + @Test(expected = CloudRuntimeException.class) + public void testStopSite2SiteVpnThrowsWhenVpcIsMissing() throws ResourceUnavailableException { + Site2SiteVpnConnection connection = mockVpnConnection(null); + + nsxElement.stopSite2SiteVpn(connection); + } + + @Test + public void testDeleteSite2SiteVpnRemovesTheProviderConnection() throws ResourceUnavailableException { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + Site2SiteVpnConnection connection = mockVpnConnection(vpcVO); + when(connection.getUuid()).thenReturn("conn-uuid"); + when(nsxService.deleteVpnConnection(vpcVO, "conn-uuid")).thenReturn(true); + + assertTrue(nsxElement.deleteSite2SiteVpn(connection)); + } + + @Test(expected = CloudRuntimeException.class) + public void testStopSite2SiteVpnThrowsWhenVpnGatewayIsMissing() throws ResourceUnavailableException { + Site2SiteVpnConnection connection = Mockito.mock(Site2SiteVpnConnection.class); + when(connection.getVpnGatewayId()).thenReturn(7L); + when(vpnGatewayDao.findById(7L)).thenReturn(null); + + nsxElement.stopSite2SiteVpn(connection); + } } diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java index 41f47bc610e5..a116d36756fa 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java @@ -18,12 +18,18 @@ import com.cloud.network.IpAddress; import com.cloud.network.dao.NetworkVO; +import com.cloud.network.Site2SiteVpnConnection; +import com.cloud.network.dao.Site2SiteVpnConnectionVO; +import com.cloud.network.vpc.Vpc; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; +import com.cloud.network.nsx.NsxVpnGatewayResult; +import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.net.Ip; import org.apache.cloudstack.NsxAnswer; import org.apache.cloudstack.agent.api.CreateNsxStaticNatCommand; import org.apache.cloudstack.agent.api.CreateNsxTier1GatewayCommand; +import org.apache.cloudstack.agent.api.CreateNsxVpnGatewayCommand; import org.apache.cloudstack.agent.api.CreateOrUpdateNsxTier1NatRuleCommand; import org.apache.cloudstack.agent.api.DeleteNsxNatRuleCommand; import org.apache.cloudstack.agent.api.DeleteNsxSegmentCommand; @@ -38,6 +44,11 @@ import org.mockito.MockitoAnnotations; import org.mockito.junit.MockitoJUnitRunner; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -81,6 +92,25 @@ public void testCreateVpcNetwork() { assertTrue(nsxService.createVpcNetwork(1L, 3L, 2L, 5L, "VPC01", false)); } + @Test + public void testCreateVpnGatewayPreservesStructuredFailureResult() { + Vpc vpc = mock(Vpc.class); + when(vpc.getDomainId()).thenReturn(domainId); + when(vpc.getAccountId()).thenReturn(accountId); + when(vpc.getZoneId()).thenReturn(zoneId); + when(vpc.getId()).thenReturn(3L); + NsxAnswer answer = mock(NsxAnswer.class); + when(answer.getResult()).thenReturn(false); + when(answer.isEndpointMayBeInUse()).thenReturn(true); + when(nsxControllerUtils.sendNsxCommandForResult(any(CreateNsxVpnGatewayCommand.class), eq(zoneId))) + .thenReturn(answer); + + NsxVpnGatewayResult result = nsxService.createVpnGateway(vpc, "203.0.113.20"); + + assertFalse(result.isSuccessful()); + assertTrue(result.isEndpointMayBeInUse()); + } + @Test public void testDeleteVpcNetwork() { NsxAnswer deleteNsxTier1GatewayAnswer = mock(NsxAnswer.class); @@ -159,4 +189,54 @@ public void testDeleteStaticNatRule() { nsxService.deleteStaticNatRule(zoneId, domainId, accountId, networkId, networkName, true); Mockito.verify(nsxControllerUtils).sendNsxCommand(any(DeleteNsxNatRuleCommand.class), eq(zoneId)); } + + @Test + public void testPollVpnConnectionStatusTransitionsUp() { + Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); + VpcVO vpc = mock(VpcVO.class); + AtomicReference transitionedState = new AtomicReference<>(); + + NsxServiceImpl service = new NsxServiceImpl() { + @Override + public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + return "UP"; + } + + @Override + protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, + Site2SiteVpnConnection.State newState) { + transitionedState.set(newState); + } + }; + + service.pollVpnConnectionStatus(connection, vpc); + + assertEquals(Site2SiteVpnConnection.State.Connected, transitionedState.get()); + } + + @Test + public void testPollVpnConnectionStatusDoesNotTransitionOnQueryFailure() { + Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); + VpcVO vpc = mock(VpcVO.class); + when(connection.getId()).thenReturn(11L); + AtomicBoolean transitioned = new AtomicBoolean(); + + NsxServiceImpl service = new NsxServiceImpl() { + @Override + public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + throw new CloudRuntimeException("NSX unavailable"); + } + + @Override + protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, + Site2SiteVpnConnection.State newState) { + transitioned.set(true); + } + }; + + service.pollVpnConnectionStatus(connection, vpc); + + // A transient management-plane error must not turn a valid connection into Error. + assertTrue(!transitioned.get()); + } } diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxControllerUtilsTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxControllerUtilsTest.java index 9139fdef68f7..9168cd04960b 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxControllerUtilsTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxControllerUtilsTest.java @@ -84,6 +84,31 @@ public void testSendCommand() { Assert.assertNotNull(nsxAnswer); } + @Test(expected = InvalidParameterValueException.class) + public void testSendCommandRejectsFailedNsxAnswer() { + NsxCommand cmd = Mockito.mock(NsxCommand.class); + NsxAnswer answer = Mockito.mock(NsxAnswer.class); + Mockito.when(answer.getResult()).thenReturn(false); + Mockito.when(agentMgr.easySend(nsxProviderHostId, cmd)).thenReturn(answer); + + nsxControllerUtils.sendNsxCommand(cmd, zoneId); + } + + @Test + public void testSendCommandForResultPreservesFailedNsxAnswer() { + NsxCommand cmd = Mockito.mock(NsxCommand.class); + NsxAnswer answer = Mockito.mock(NsxAnswer.class); + Mockito.when(answer.getResult()).thenReturn(false); + Mockito.when(answer.isEndpointMayBeInUse()).thenReturn(true); + Mockito.when(agentMgr.easySend(nsxProviderHostId, cmd)).thenReturn(answer); + + NsxAnswer nsxAnswer = nsxControllerUtils.sendNsxCommandForResult(cmd, zoneId); + + Assert.assertSame(answer, nsxAnswer); + Assert.assertFalse(nsxAnswer.getResult()); + Assert.assertTrue(nsxAnswer.isEndpointMayBeInUse()); + } + @Test public void testGetNsxNatRuleIdForVpc() { long vpcId = 5L; diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxHelperTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxHelperTest.java new file mode 100644 index 000000000000..8d32e2499110 --- /dev/null +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxHelperTest.java @@ -0,0 +1,53 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.utils; + +import org.junit.Test; + +import com.cloud.utils.Pair; + +import static org.junit.Assert.assertEquals; + +public class NsxHelperTest { + + @Test + public void testVpnVtiAddressPairFirstSlot() { + Pair vtiAddresses = NsxHelper.getVpnVtiAddressPair(0L); + assertEquals("169.254.64.1", vtiAddresses.first()); + assertEquals("169.254.64.2", vtiAddresses.second()); + } + + @Test + public void testVpnVtiAddressPairIsDerivedFromConnectionId() { + Pair vtiAddresses = NsxHelper.getVpnVtiAddressPair(5L); + assertEquals("169.254.64.21", vtiAddresses.first()); + assertEquals("169.254.64.22", vtiAddresses.second()); + } + + @Test + public void testVpnVtiAddressPairLastSlotStaysWithinSubnet() { + Pair vtiAddresses = NsxHelper.getVpnVtiAddressPair(4095L); + assertEquals("169.254.127.253", vtiAddresses.first()); + assertEquals("169.254.127.254", vtiAddresses.second()); + } + + @Test + public void testVpnVtiAddressPairWrapsAfterSubnetIsExhausted() { + assertEquals(NsxHelper.getVpnVtiAddressPair(1L), NsxHelper.getVpnVtiAddressPair(4097L)); + } + +} diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxVpnCryptoUtilsTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxVpnCryptoUtilsTest.java new file mode 100644 index 000000000000..3eb93ff3d065 --- /dev/null +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxVpnCryptoUtilsTest.java @@ -0,0 +1,174 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.utils; + +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.junit.Test; + +import com.cloud.exception.InvalidParameterValueException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class NsxVpnCryptoUtilsTest { + + @Test + public void testEncryptionAlgorithmMapping() { + assertEquals(List.of("AES_128"), NsxVpnCryptoUtils.getEncryptionAlgorithms("aes128-sha256;modp2048")); + assertEquals(List.of("AES_256"), NsxVpnCryptoUtils.getEncryptionAlgorithms("aes256-sha1")); + assertEquals(List.of("AES_128", "AES_256"), NsxVpnCryptoUtils.getEncryptionAlgorithms("aes128-sha256,aes256-sha512;modp2048")); + } + + @Test + public void testDigestAlgorithmMapping() { + assertEquals(List.of("SHA1"), NsxVpnCryptoUtils.getDigestAlgorithms("aes128-sha1")); + assertEquals(List.of("SHA2_256"), NsxVpnCryptoUtils.getDigestAlgorithms("aes128-sha256")); + assertEquals(List.of("SHA2_384"), NsxVpnCryptoUtils.getDigestAlgorithms("aes128-sha384")); + assertEquals(List.of("SHA2_512"), NsxVpnCryptoUtils.getDigestAlgorithms("aes128-sha512")); + } + + @Test + public void testDhGroupMapping() { + assertEquals(List.of("GROUP2"), NsxVpnCryptoUtils.getDhGroups("aes128-sha256;modp1024")); + assertEquals(List.of("GROUP5"), NsxVpnCryptoUtils.getDhGroups("aes128-sha256;modp1536")); + assertEquals(List.of("GROUP14"), NsxVpnCryptoUtils.getDhGroups("aes128-sha256;modp2048")); + assertEquals(List.of("GROUP15"), NsxVpnCryptoUtils.getDhGroups("aes128-sha256;modp3072")); + assertEquals(List.of("GROUP16"), NsxVpnCryptoUtils.getDhGroups("aes128-sha256;modp4096")); + } + + @Test + public void testDhGroupsEmptyWhenPolicyHasNoDhPart() { + assertTrue(NsxVpnCryptoUtils.getDhGroups("aes128-sha256").isEmpty()); + } + + @Test + public void testIkeMultiProposalPolicyWithDhGroupPerEntry() { + String policy = "aes128-sha1;modp2048,aes256-sha256;modp2048"; + assertEquals(List.of("AES_128", "AES_256"), NsxVpnCryptoUtils.getEncryptionAlgorithms(policy)); + assertEquals(List.of("SHA1", "SHA2_256"), NsxVpnCryptoUtils.getDigestAlgorithms(policy)); + assertEquals(List.of("GROUP14"), NsxVpnCryptoUtils.getDhGroups(policy)); + } + + @Test + public void testMultiProposalPolicyCollectsAllDhGroups() { + String policy = "aes128-sha1;modp2048,aes256-sha256;modp3072"; + assertEquals(List.of("GROUP14", "GROUP15"), NsxVpnCryptoUtils.getDhGroups(policy)); + } + + @Test + public void testEspPolicyWithoutDhGroup() { + assertEquals(List.of("AES_128"), NsxVpnCryptoUtils.getEncryptionAlgorithms("aes128-sha256")); + assertEquals(List.of("SHA2_256"), NsxVpnCryptoUtils.getDigestAlgorithms("aes128-sha256")); + assertTrue(NsxVpnCryptoUtils.getDhGroups("aes128-sha256").isEmpty()); + } + + @Test + public void testIkeVersionMapping() { + assertEquals("IKE_FLEX", NsxVpnCryptoUtils.getIkeVersion("ike")); + assertEquals("IKE_FLEX", NsxVpnCryptoUtils.getIkeVersion(null)); + assertEquals("IKE_V1", NsxVpnCryptoUtils.getIkeVersion("ikev1")); + assertEquals("IKE_V2", NsxVpnCryptoUtils.getIkeVersion("ikev2")); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejects3des() { + NsxVpnCryptoUtils.getEncryptionAlgorithms("3des-sha1;modp2048"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsAes192() { + NsxVpnCryptoUtils.getEncryptionAlgorithms("aes192-sha256;modp2048"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsMd5() { + NsxVpnCryptoUtils.getDigestAlgorithms("aes128-md5;modp2048"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsModp6144() { + NsxVpnCryptoUtils.getDhGroups("aes128-sha256;modp6144"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsModp8192() { + NsxVpnCryptoUtils.getDhGroups("aes128-sha256;modp8192"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsModp1024s160() { + NsxVpnCryptoUtils.getDhGroups("aes128-sha256;modp1024s160"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsModp2048s224() { + NsxVpnCryptoUtils.getDhGroups("aes128-sha256;modp2048s224"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsModp2048s256() { + NsxVpnCryptoUtils.getDhGroups("aes128-sha256;modp2048s256"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsCurve25519() { + NsxVpnCryptoUtils.getDhGroups("aes128-sha256;curve25519"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsUnsupportedIkeVersion() { + NsxVpnCryptoUtils.getIkeVersion("ikev3"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsIkeLifetimeBelowNsxMinimum() { + NsxVpnCryptoUtils.validateIkeLifetime(3600L); + } + + @Test + public void testAcceptsIkeLifetimeAtNsxMinimum() { + NsxVpnCryptoUtils.validateIkeLifetime(21600L); + NsxVpnCryptoUtils.validateIkeLifetime(86400L); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsEspLifetimeBelowNsxMinimum() { + NsxVpnCryptoUtils.validateEspLifetime(300L); + } + + @Test + public void testAcceptsDefaultEspLifetime() { + NsxVpnCryptoUtils.validateEspLifetime(3600L); + } + + @Test(expected = InvalidParameterValueException.class) + public void testRejectsPresharedKeyOverNsxMaximumLength() { + NsxVpnCryptoUtils.validatePresharedKey(StringUtils.repeat('k', 129)); + } + + @Test + public void testValidateAcceptsSupportedParameters() { + NsxVpnCryptoUtils.validate("aes256-sha256;modp2048", "aes128-sha1", "ikev2", 86400L, 3600L, "presharedkey"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidateRejectsUnsupportedEspPolicy() { + NsxVpnCryptoUtils.validate("aes256-sha256;modp2048", "3des-md5", "ikev2", 86400L, 3600L, "presharedkey"); + } +} diff --git a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java index 47362feb4d15..60aa96b955cb 100644 --- a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java @@ -62,6 +62,7 @@ import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.PermissionDeniedException; import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.IpAddress; import com.cloud.network.IpAddressManager; import com.cloud.network.Network; import com.cloud.network.Site2SiteCustomerGateway; @@ -76,6 +77,7 @@ import com.cloud.network.dao.Site2SiteVpnConnectionVO; import com.cloud.network.dao.Site2SiteVpnGatewayDao; import com.cloud.network.dao.Site2SiteVpnGatewayVO; +import com.cloud.network.element.NetworkElement; import com.cloud.network.element.Site2SiteVpnServiceProvider; import com.cloud.network.vpc.Vpc; import com.cloud.network.vpc.VpcManager; @@ -214,26 +216,118 @@ public Site2SiteVpnGateway createVpnGateway(CreateVpnGatewayCmd cmd) { throw new InvalidParameterValueException(String.format("The VPN gateway of VPC %s already exists!", vpc)); } - IPAddressVO requestedIp = _ipAddressDao.findById(cmd.getIpAddressId()); - IPAddressVO ipAddress = getIpAddressIdForVpn(vpcId, vpc.getVpcOfferingId(), requestedIp); + Site2SiteVpnServiceProvider provider = getVpnServiceProviderForVpc(vpcId); + if (provider == null) { + throw new InvalidParameterValueException(String.format( + "Site-to-Site VPN is not supported by %s: the VPC offering does not provide the Vpn service through any available VPN provider", + vpc)); + } + + Long requestedIpId = cmd.getIpAddressId(); + IPAddressVO requestedIp = requestedIpId == null ? null : _ipAddressDao.findById(requestedIpId); + if (requestedIpId != null && requestedIp == null) { + throw new InvalidParameterValueException(String.format( + "Unable to find the requested VPN gateway IP with id %s", requestedIpId)); + } + IPAddressVO ipAddress = getIpAddressIdForVpn(vpc, provider, requestedIp); Site2SiteVpnGatewayVO gw = new Site2SiteVpnGatewayVO(owner.getAccountId(), owner.getDomainId(), ipAddress.getId(), vpcId); if (cmd.getDisplay() != null) { gw.setDisplay(cmd.getDisplay()); } - _vpnGatewayDao.persist(gw); + try { + _vpnGatewayDao.persist(gw); + } catch (RuntimeException e) { + try { + provider.releaseVpnGatewayIp(gw); + } catch (Exception releaseException) { + logger.warn("Failed to release the VPN gateway resources of VPC {} after the gateway could not be persisted: {}", + vpc, releaseException.getMessage()); + } + throw e; + } return gw; } - private IPAddressVO getIpAddressIdForVpn(Long vpcId, Long vpcOferingId, IPAddressVO requestedIp) { - VpcOfferingServiceMapVO mapForSourceNat = vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId(Network.Service.SourceNat.getName(), Network.Provider.VPCVirtualRouter.getName(), vpcOferingId); - VpcOfferingServiceMapVO mapForVpn = vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId(Network.Service.Vpn.getName(), Network.Provider.VPCVirtualRouter.getName(), vpcOferingId); + private List getVpnServiceProvidersForVpc(long vpcId) { + List providers = new ArrayList<>(); + for (Site2SiteVpnServiceProvider provider : _s2sProviders) { + if (provider instanceof NetworkElement + && vpcManager.isProviderSupportServiceInVpc(vpcId, Network.Service.Vpn, + ((NetworkElement) provider).getProvider())) { + providers.add(provider); + } + } + return providers; + } + + private Site2SiteVpnServiceProvider getVpnServiceProviderForVpc(long vpcId) { + List providers = getVpnServiceProvidersForVpc(vpcId); + if (providers.size() > 1) { + throw new InvalidParameterValueException(String.format( + "VPC %s has more than one Site-to-Site VPN provider; exactly one provider must be configured", + vpcId)); + } + return providers.isEmpty() ? null : providers.get(0); + } + + private Site2SiteVpnServiceProvider getVpnServiceProviderForGateway(Site2SiteVpnGateway gateway) { + List ownedProviders = new ArrayList<>(); + for (Site2SiteVpnServiceProvider provider : _s2sProviders) { + if (provider.ownsVpnGateway(gateway)) { + ownedProviders.add(provider); + } + } + if (ownedProviders.size() > 1) { + throw new CloudRuntimeException(String.format( + "VPN gateway %s is claimed by more than one Site-to-Site VPN provider", gateway.getId())); + } + if (!ownedProviders.isEmpty()) { + return ownedProviders.get(0); + } + // Gateways created before provider ownership was persisted were all terminated by the + // VPC virtual router. Keep their lifecycle on that provider even if the offering changes. + for (Site2SiteVpnServiceProvider provider : _s2sProviders) { + if (provider instanceof NetworkElement + && Network.Provider.VPCVirtualRouter.equals(((NetworkElement) provider).getProvider())) { + return provider; + } + } + return null; + } + + private IPAddressVO getIpAddressIdForVpn(Vpc vpc, Site2SiteVpnServiceProvider provider, IPAddressVO requestedIp) { + IpAddress providerIp = provider.acquireVpnGatewayIp(vpc, requestedIp); + if (providerIp != null) { + logger.debug("Using VPN gateway IP {} supplied by provider {} for VPC {}", + providerIp.getAddress(), provider.getName(), vpc); + IPAddressVO providerIpRow = _ipAddressDao.findById(providerIp.getId()); + if (providerIpRow == null) { + releaseProviderVpnGatewayIp(provider, vpc, providerIp.getId()); + throw new CloudRuntimeException(String.format( + "VPN provider %s returned IP id %s for VPC %s, but the IP no longer exists", + provider.getName(), providerIp.getId(), vpc.getId())); + } + boolean addressMismatch = providerIp.getAddress() == null || providerIpRow.getAddress() == null + || !providerIp.getAddress().addr().equals(providerIpRow.getAddress().addr()); + if (providerIpRow.getRemoved() != null || !providerIpRow.readyToUse() + || providerIpRow.getVpcId() == null || providerIpRow.getVpcId() != vpc.getId() + || providerIpRow.isSourceNat() || providerIpRow.isForSystemVms() || addressMismatch) { + releaseProviderVpnGatewayIp(provider, vpc, providerIp.getId()); + throw new CloudRuntimeException(String.format( + "VPN provider %s returned IP id %s that is not an active, dedicated IP of VPC %s", + provider.getName(), providerIp.getId(), vpc.getId())); + } + return providerIpRow; + } + + VpcOfferingServiceMapVO mapForSourceNat = vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId(Network.Service.SourceNat.getName(), Network.Provider.VPCVirtualRouter.getName(), vpc.getVpcOfferingId()); + VpcOfferingServiceMapVO mapForVpn = vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId(Network.Service.Vpn.getName(), Network.Provider.VPCVirtualRouter.getName(), vpc.getVpcOfferingId()); if (mapForSourceNat == null && mapForVpn != null) { // Use Static NAT IP of VPC VR logger.debug(String.format("The VPC VR provides %s Service, however it does not provide %s service, trying to configure using IP of VPC VR", Network.Service.Vpn.getName(), Network.Service.SourceNat.getName())); - Vpc vpc = _vpcDao.findById(vpcId); IPAddressVO ipAddressForVpcVR = vpcManager.getIpAddressForVpcVr(vpc, requestedIp, true); if (!vpcManager.configStaticNatForVpcVr(vpc, ipAddressForVpcVR)) { throw new CloudRuntimeException("Failed to enable static nat for VPC VR as part of vpn gateway"); @@ -241,9 +335,9 @@ private IPAddressVO getIpAddressIdForVpn(Long vpcId, Long vpcOferingId, IPAddres return ipAddressForVpcVR; } else { //Use source NAT ip for VPC - List ips = _ipAddressDao.listByAssociatedVpc(vpcId, true); + List ips = _ipAddressDao.listByAssociatedVpc(vpc.getId(), true); if (ips.size() != 1) { - throw new CloudRuntimeException("Cannot found source nat ip of vpc " + vpcId); + throw new CloudRuntimeException("Cannot find a unique source NAT IP for VPC " + vpc.getId()); } if (requestedIp != null && requestedIp.getId() != ips.get(0).getId()) { throw new CloudRuntimeException(String.format("Cannot use requested IP %s as it is not the Source NAT IP", requestedIp.getAddress().addr())); @@ -252,6 +346,16 @@ private IPAddressVO getIpAddressIdForVpn(Long vpcId, Long vpcOferingId, IPAddres } } + private void releaseProviderVpnGatewayIp(Site2SiteVpnServiceProvider provider, Vpc vpc, long ipAddressId) { + try { + provider.releaseVpnGatewayIp(new Site2SiteVpnGatewayVO(vpc.getAccountId(), + vpc.getDomainId(), ipAddressId, vpc.getId())); + } catch (Exception cleanupException) { + logger.warn("Failed to clean up VPN provider resources for IP {} of VPC {} after provider {} returned an invalid address: {}", + ipAddressId, vpc.getId(), provider.getName(), cleanupException.getMessage()); + } + } + private void validateVpnCryptographicParameters(String ikePolicy, String espPolicy, String ikeVersion, Long domainId) { String excludedEncryption = VpnCustomerGatewayExcludedEncryptionAlgorithms.valueIn(domainId); String excludedHashing = VpnCustomerGatewayExcludedHashingAlgorithms.valueIn(domainId); @@ -478,6 +582,7 @@ public Site2SiteVpnConnection createVpnConnection(CreateVpnConnectionCmd cmd) { validateVpnConnectionOfTheRightAccount(customerGateway, vpnGateway); validateVpnConnectionDoesntExist(customerGateway, vpnGateway); validatePrerequisiteVpnGateway(vpnGateway); + validateCustomerGatewayForVpnGateway(customerGateway, vpnGateway); String[] cidrList = customerGateway.getGuestCidrList().split(","); @@ -559,6 +664,16 @@ private void validatePrerequisiteVpnGateway(Site2SiteVpnGateway vpnGateway) { } } + private void validateCustomerGatewayForVpnGateway(Site2SiteCustomerGateway customerGateway, + Site2SiteVpnGateway vpnGateway) { + Site2SiteVpnServiceProvider provider = getVpnServiceProviderForGateway(vpnGateway); + if (provider == null) { + throw new InvalidParameterValueException(String.format( + "No Site-to-Site VPN provider owns gateway %s", vpnGateway.getId())); + } + provider.validateSite2SiteVpnCustomerGateway(customerGateway); + } + @Override @DB @ActionEvent(eventType = EventTypes.EVENT_S2S_VPN_CONNECTION_CREATE, eventDescription = "starting s2s vpn connection", async = true) @@ -577,16 +692,22 @@ public Site2SiteVpnConnection startVpnConnection(long id) throws ResourceUnavail _vpnConnectionDao.persist(conn); final Site2SiteVpnGateway vpnGateway = _vpnGatewayDao.findById(conn.getVpnGatewayId()); + if (vpnGateway == null) { + throw new CloudRuntimeException(String.format("Unable to find VPN gateway %s for connection %s", + conn.getVpnGatewayId(), conn.getUuid())); + } try { vpcManager.applyStaticRouteForVpcVpnIfNeeded(vpnGateway.getVpcId(), false); } catch (ResourceUnavailableException | CloudRuntimeException e) { logger.error("Unable to apply static routes for vpc " + vpnGateway.getVpcId() + "as part of start of VPN connection, due to " + e.getMessage()); } - boolean result = true; - for (Site2SiteVpnServiceProvider element : _s2sProviders) { - result = result & element.startSite2SiteVpn(conn); + Site2SiteVpnServiceProvider provider = getVpnServiceProviderForGateway(vpnGateway); + if (provider == null) { + throw new InvalidParameterValueException(String.format( + "No Site-to-Site VPN provider owns gateway %s", vpnGateway.getId())); } + boolean result = provider.startSite2SiteVpn(conn); if (result) { if (conn.isPassive()) { @@ -600,6 +721,15 @@ public Site2SiteVpnConnection startVpnConnection(long id) throws ResourceUnavail conn.setState(State.Error); _vpnConnectionDao.persist(conn); throw new ResourceUnavailableException("Failed to apply site-to-site VPN", Site2SiteVpnConnection.class, id); + } catch (ResourceUnavailableException | RuntimeException e) { + // Provider validation and provisioning failures must not leave a connection Pending. + // Pending is reserved for work that has been accepted and can be retried by the + // normal lifecycle; a synchronous failure is an actionable Error state. + if (conn.getState() == State.Pending) { + conn.setState(State.Error); + _vpnConnectionDao.persist(conn); + } + throw e; } finally { _vpnConnectionDao.releaseFromLockTable(conn.getId()); } @@ -643,6 +773,11 @@ protected void doDeleteVpnGateway(Site2SiteVpnGateway gw) { if (!CollectionUtils.isEmpty(conns)) { throw new InvalidParameterValueException(String.format("Unable to delete VPN gateway %s because there is still related VPN connections!", gw)); } + Site2SiteVpnServiceProvider provider = getVpnServiceProviderForGateway(gw); + if (provider == null) { + throw new CloudRuntimeException(String.format("No Site-to-Site VPN provider owns gateway %s", gw.getId())); + } + provider.releaseVpnGatewayIp(gw); _vpnGatewayDao.remove(gw.getId()); } @@ -736,6 +871,23 @@ public Site2SiteCustomerGateway updateCustomerGateway(UpdateVpnCustomerGatewayCm throw new InvalidParameterValueException("The customer gateway with name " + name + " already exists!"); } + String effectiveIkeVersion = ikeVersion == null ? gw.getIkeVersion() : ikeVersion; + Site2SiteCustomerGatewayVO proposedGateway = new Site2SiteCustomerGatewayVO(name, accountId, gw.getDomainId(), + gatewayIp, guestCidrList, ipsecPsk, ikePolicy, espPolicy, ikeLifetime, espLifetime, dpd, encap, + splitConnections, effectiveIkeVersion); + List existingConnections = _vpnConnectionDao.listByCustomerGatewayId(id); + if (existingConnections != null) { + for (Site2SiteVpnConnectionVO connection : existingConnections) { + Site2SiteVpnGatewayVO gateway = _vpnGatewayDao.findById(connection.getVpnGatewayId()); + if (gateway == null) { + throw new CloudRuntimeException(String.format( + "Unable to validate customer gateway %s because VPN gateway %s does not exist", + id, connection.getVpnGatewayId())); + } + validateCustomerGatewayForVpnGateway(proposedGateway, gateway); + } + } + gw.setName(name); gw.setGatewayIp(gatewayIp); gw.setGuestCidrList(guestCidrList); @@ -801,9 +953,7 @@ public boolean deleteVpnConnection(DeleteVpnConnectionCmd cmd) throws ResourceUn _accountMgr.checkAccess(caller, null, false, conn); - if (conn.getState() != State.Pending) { - stopVpnConnection(id); - } + stopVpnConnection(id, true); conn.setState(State.Removed); _vpnConnectionDao.update(id, conn); @@ -822,22 +972,34 @@ public boolean deleteVpnConnection(DeleteVpnConnectionCmd cmd) throws ResourceUn @DB private void stopVpnConnection(Long id) throws ResourceUnavailableException { + stopVpnConnection(id, false); + } + + @DB + private void stopVpnConnection(Long id, boolean deleting) throws ResourceUnavailableException { Site2SiteVpnConnectionVO conn = _vpnConnectionDao.acquireInLockTable(id); if (conn == null) { throw new CloudRuntimeException("Unable to acquire lock for stopping VPN connection with ID " + id); } try { - if (conn.getState() == State.Pending) { + if (conn.getState() == State.Pending && !deleting) { throw new InvalidParameterValueException("Site to site VPN connection with specified id is currently Pending, unable to Disconnect!"); } conn.setState(State.Disconnected); _vpnConnectionDao.persist(conn); - boolean result = true; - for (Site2SiteVpnServiceProvider element : _s2sProviders) { - result = result & element.stopSite2SiteVpn(conn); + Site2SiteVpnGateway vpnGateway = _vpnGatewayDao.findById(conn.getVpnGatewayId()); + if (vpnGateway == null) { + throw new CloudRuntimeException(String.format("Unable to find VPN gateway %s for connection %s", + conn.getVpnGatewayId(), conn.getUuid())); + } + Site2SiteVpnServiceProvider provider = getVpnServiceProviderForGateway(vpnGateway); + if (provider == null) { + throw new CloudRuntimeException(String.format( + "No Site-to-Site VPN provider owns gateway %s", vpnGateway.getId())); } + boolean result = deleting ? provider.deleteSite2SiteVpn(conn) : provider.stopSite2SiteVpn(conn); if (!result) { conn.setState(State.Error); @@ -1067,6 +1229,15 @@ public List getConnectionsForRouter(DomainRouterVO rou if (router.getVpcId() == null) { return conns; } + Site2SiteVpnGatewayVO gateway = _vpnGatewayDao.findByVpcId(vpcId); + if (gateway == null) { + return conns; + } + Site2SiteVpnServiceProvider provider = getVpnServiceProviderForGateway(gateway); + if (!(provider instanceof NetworkElement) + || !Network.Provider.VPCVirtualRouter.equals(((NetworkElement) provider).getProvider())) { + return conns; + } conns.addAll(_vpnConnectionDao.listByVpcId(vpcId)); return conns; } diff --git a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java index 291d3a4aa812..14909b057665 100644 --- a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java @@ -23,6 +23,7 @@ import com.cloud.network.Site2SiteVpnConnection; import com.cloud.network.Site2SiteVpnConnection.State; import com.cloud.network.Site2SiteVpnGateway; +import com.cloud.network.Network; import com.cloud.network.dao.IPAddressDao; import com.cloud.network.dao.IPAddressVO; import com.cloud.network.dao.Site2SiteCustomerGatewayDao; @@ -32,6 +33,7 @@ import com.cloud.network.dao.Site2SiteVpnGatewayDao; import com.cloud.network.dao.Site2SiteVpnGatewayVO; import com.cloud.network.element.Site2SiteVpnServiceProvider; +import com.cloud.network.element.NetworkElement; import com.cloud.network.vpc.VpcManager; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; @@ -42,6 +44,7 @@ import com.cloud.user.User; import com.cloud.user.UserVO; import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.net.Ip; import com.cloud.utils.net.NetUtils; import com.cloud.vm.DomainRouterVO; import org.apache.cloudstack.acl.SecurityChecker; @@ -53,6 +56,7 @@ import org.apache.cloudstack.api.command.user.vpn.DeleteVpnCustomerGatewayCmd; import org.apache.cloudstack.api.command.user.vpn.DeleteVpnGatewayCmd; import org.apache.cloudstack.api.command.user.vpn.ResetVpnConnectionCmd; +import org.apache.cloudstack.api.command.user.vpn.UpdateVpnCustomerGatewayCmd; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.config.ConfigKey; import org.junit.After; @@ -74,6 +78,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyLong; @@ -165,6 +170,27 @@ public void setUp() throws Exception { when(_accountMgr.getAccount(ACCOUNT_ID)).thenReturn(account); doNothing().when(_accountMgr).checkAccess(any(Account.class), nullable(SecurityChecker.AccessType.class), anyBoolean(), any()); + when(_s2sProviders.iterator()).thenReturn(List.of().iterator()); + } + + private Site2SiteVpnServiceProvider mockVpcVirtualRouterProvider() { + Site2SiteVpnServiceProvider provider = mock(Site2SiteVpnServiceProvider.class, + Mockito.withSettings().extraInterfaces(NetworkElement.class)); + when(((NetworkElement) provider).getProvider()).thenReturn(Network.Provider.VPCVirtualRouter); + when(_vpcMgr.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, Network.Provider.VPCVirtualRouter)) + .thenReturn(true); + when(_s2sProviders.iterator()).thenAnswer(invocation -> List.of(provider).iterator()); + return provider; + } + + private Site2SiteVpnServiceProvider mockNsxProvider() { + Site2SiteVpnServiceProvider provider = mock(Site2SiteVpnServiceProvider.class, + Mockito.withSettings().extraInterfaces(NetworkElement.class)); + when(((NetworkElement) provider).getProvider()).thenReturn(Network.Provider.Nsx); + when(_vpcMgr.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, Network.Provider.Nsx)) + .thenReturn(true); + when(_s2sProviders.iterator()).thenAnswer(invocation -> List.of(provider).iterator()); + return provider; } @After @@ -214,9 +240,11 @@ public void testCreateVpnGatewaySuccess() { when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); when(cmd.isDisplay()).thenReturn(true); + when(cmd.getIpAddressId()).thenReturn(null); when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + mockVpcVirtualRouterProvider(); when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(List.of(ipAddress)); when(_vpnGatewayDao.persist(any(Site2SiteVpnGatewayVO.class))).thenReturn(vpnGateway); @@ -226,6 +254,48 @@ public void testCreateVpnGatewaySuccess() { verify(_vpnGatewayDao).persist(any(Site2SiteVpnGatewayVO.class)); } + @Test + public void testCreateVpnGatewayAcceptsValidProviderOwnedIp() { + CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); + when(cmd.getVpcId()).thenReturn(VPC_ID); + when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(cmd.getIpAddressId()).thenReturn(null); + when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + Site2SiteVpnServiceProvider provider = mockNsxProvider(); + when(provider.acquireVpnGatewayIp(vpc, null)).thenReturn(ipAddress); + when(_ipAddressDao.findById(IP_ADDRESS_ID)).thenReturn(ipAddress); + when(ipAddress.getAddress()).thenReturn(new Ip("203.0.113.10")); + when(ipAddress.readyToUse()).thenReturn(true); + when(_vpnGatewayDao.persist(any(Site2SiteVpnGatewayVO.class))).thenReturn(vpnGateway); + + Site2SiteVpnGateway result = site2SiteVpnManager.createVpnGateway(cmd); + + assertNotNull(result); + verify(provider, never()).releaseVpnGatewayIp(any(Site2SiteVpnGateway.class)); + } + + @Test + public void testCreateVpnGatewayRejectsProviderIpOutsideVpcAndCleansUp() { + CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); + when(cmd.getVpcId()).thenReturn(VPC_ID); + when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(cmd.getIpAddressId()).thenReturn(null); + when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + Site2SiteVpnServiceProvider provider = mockNsxProvider(); + when(provider.acquireVpnGatewayIp(vpc, null)).thenReturn(ipAddress); + when(_ipAddressDao.findById(IP_ADDRESS_ID)).thenReturn(ipAddress); + when(ipAddress.getAddress()).thenReturn(new Ip("203.0.113.10")); + when(ipAddress.readyToUse()).thenReturn(true); + when(ipAddress.getVpcId()).thenReturn(99L); + + assertThrows(CloudRuntimeException.class, () -> site2SiteVpnManager.createVpnGateway(cmd)); + + verify(provider).releaseVpnGatewayIp(any(Site2SiteVpnGateway.class)); + verify(_vpnGatewayDao, never()).persist(any(Site2SiteVpnGatewayVO.class)); + } + @Test(expected = InvalidParameterValueException.class) public void testCreateVpnGatewayInvalidVpc() { CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); @@ -257,6 +327,7 @@ public void testCreateVpnGatewayNoSourceNatIp() { when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + mockVpcVirtualRouterProvider(); when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(new ArrayList<>()); site2SiteVpnManager.createVpnGateway(cmd); @@ -477,6 +548,7 @@ public void testCreateVpnConnectionCidrOverlapWithVpc() { when(_vpnConnectionDao.findByVpnGatewayIdAndCustomerGatewayId(VPN_GATEWAY_ID, CUSTOMER_GATEWAY_ID)).thenReturn(null); when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(vpnGateway); when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + mockVpcVirtualRouterProvider(); try (MockedStatic netUtilsMock = Mockito.mockStatic(NetUtils.class)) { netUtilsMock.when(() -> NetUtils.isNetworksOverlap("10.0.0.0/16", "10.0.0.0/24")).thenReturn(true); @@ -497,6 +569,7 @@ public void testCreateVpnConnectionExceedsLimit() { when(_vpnConnectionDao.findByVpnGatewayIdAndCustomerGatewayId(VPN_GATEWAY_ID, CUSTOMER_GATEWAY_ID)).thenReturn(null); when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(vpnGateway); when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + mockVpcVirtualRouterProvider(); List existingConns = new ArrayList<>(); for (int i = 0; i < 4; i++) { @@ -507,6 +580,59 @@ public void testCreateVpnConnectionExceedsLimit() { site2SiteVpnManager.createVpnConnection(cmd); } + @Test + public void testCreateVpnConnectionValidatesCustomerGatewayWithOwningProviderBeforePersist() { + CreateVpnConnectionCmd cmd = mock(CreateVpnConnectionCmd.class); + when(cmd.getVpnGatewayId()).thenReturn(VPN_GATEWAY_ID); + when(cmd.getCustomerGatewayId()).thenReturn(CUSTOMER_GATEWAY_ID); + when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(_customerGatewayDao.findById(CUSTOMER_GATEWAY_ID)).thenReturn(customerGateway); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + when(_vpnConnectionDao.findByVpnGatewayIdAndCustomerGatewayId(VPN_GATEWAY_ID, CUSTOMER_GATEWAY_ID)).thenReturn(null); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(vpnGateway); + Site2SiteVpnServiceProvider provider = mockNsxProvider(); + when(provider.ownsVpnGateway(vpnGateway)).thenReturn(true); + Mockito.doThrow(new InvalidParameterValueException("unsupported by provider")) + .when(provider).validateSite2SiteVpnCustomerGateway(customerGateway); + + assertThrows(InvalidParameterValueException.class, () -> site2SiteVpnManager.createVpnConnection(cmd)); + + verify(_vpnConnectionDao, never()).persist(any(Site2SiteVpnConnectionVO.class)); + } + + @Test + public void testUpdateCustomerGatewayValidatesProposedValuesBeforePersist() { + UpdateVpnCustomerGatewayCmd cmd = mock(UpdateVpnCustomerGatewayCmd.class); + when(cmd.getId()).thenReturn(CUSTOMER_GATEWAY_ID); + when(cmd.getGatewayIp()).thenReturn("203.0.113.10"); + when(cmd.getGuestCidrList()).thenReturn("192.168.100.0/24"); + when(cmd.getIpsecPsk()).thenReturn("presharedkey"); + when(cmd.getIkePolicy()).thenReturn("aes256-sha256;modp2048"); + when(cmd.getEspPolicy()).thenReturn("aes256-sha256;modp2048"); + when(cmd.getIkeVersion()).thenReturn("ikev2"); + when(_customerGatewayDao.findById(CUSTOMER_GATEWAY_ID)).thenReturn(customerGateway); + when(_vpnConnectionDao.listByCustomerGatewayId(CUSTOMER_GATEWAY_ID)).thenReturn(List.of(vpnConnection)); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + Site2SiteVpnServiceProvider provider = mockNsxProvider(); + when(provider.ownsVpnGateway(vpnGateway)).thenReturn(true); + Mockito.doThrow(new InvalidParameterValueException("unsupported by provider")) + .when(provider).validateSite2SiteVpnCustomerGateway(any(Site2SiteCustomerGatewayVO.class)); + + try (MockedStatic netUtilsMock = Mockito.mockStatic(NetUtils.class)) { + netUtilsMock.when(() -> NetUtils.isValidIp4("203.0.113.10")).thenReturn(true); + netUtilsMock.when(() -> NetUtils.isValidCidrList("192.168.100.0/24")).thenReturn(true); + netUtilsMock.when(() -> NetUtils.getCleanIp4CidrList("192.168.100.0/24")) + .thenReturn("192.168.100.0/24"); + netUtilsMock.when(() -> NetUtils.isValidS2SVpnPolicy("ike", "aes256-sha256;modp2048")).thenReturn(true); + netUtilsMock.when(() -> NetUtils.isValidS2SVpnPolicy("esp", "aes256-sha256;modp2048")).thenReturn(true); + + assertThrows(InvalidParameterValueException.class, + () -> site2SiteVpnManager.updateCustomerGateway(cmd)); + } + + verify(_customerGatewayDao, never()).persist(customerGateway); + } + @Test public void testDeleteCustomerGatewaySuccess() { DeleteVpnCustomerGatewayCmd cmd = mock(DeleteVpnCustomerGatewayCmd.class); @@ -539,6 +665,7 @@ public void testDeleteVpnGatewaySuccess() { when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); when(_vpnConnectionDao.listByVpnGatewayId(VPN_GATEWAY_ID)).thenReturn(new ArrayList<>()); + mockVpcVirtualRouterProvider(); boolean result = site2SiteVpnManager.deleteVpnGateway(cmd); @@ -564,13 +691,17 @@ public void testDeleteVpnConnectionSuccess() throws ResourceUnavailableException when(_vpnConnectionDao.findById(VPN_CONNECTION_ID)).thenReturn(vpnConnection); vpnConnection.setState(State.Pending); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); when(_vpcMgr.applyStaticRouteForVpcVpnIfNeeded(anyLong(), anyBoolean())).thenReturn(true); + Site2SiteVpnServiceProvider provider = mockVpcVirtualRouterProvider(); + when(provider.deleteSite2SiteVpn(any(Site2SiteVpnConnection.class))).thenReturn(true); boolean result = site2SiteVpnManager.deleteVpnConnection(cmd); assertTrue(result); verify(_vpnConnectionDao).remove(VPN_CONNECTION_ID); + verify(provider).deleteSite2SiteVpn(vpnConnection); } @Test @@ -578,9 +709,8 @@ public void testStartVpnConnectionSuccess() throws ResourceUnavailableException when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); vpnConnection.setState(State.Pending); when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); - Site2SiteVpnServiceProvider provider = mock(Site2SiteVpnServiceProvider.class); + Site2SiteVpnServiceProvider provider = mockVpcVirtualRouterProvider(); when(provider.startSite2SiteVpn(any(Site2SiteVpnConnection.class))).thenReturn(true); - when(_s2sProviders.iterator()).thenReturn(List.of(provider).iterator()); when(_vpnConnectionDao.persist(any(Site2SiteVpnConnectionVO.class))).thenReturn(vpnConnection); when(_vpcMgr.applyStaticRouteForVpcVpnIfNeeded(anyLong(), anyBoolean())).thenReturn(true); @@ -590,6 +720,43 @@ public void testStartVpnConnectionSuccess() throws ResourceUnavailableException verify(_vpnConnectionDao, org.mockito.Mockito.atLeastOnce()).persist(any(Site2SiteVpnConnectionVO.class)); } + @Test + public void testStartVpnConnectionProviderFailureLeavesConnectionInError() throws ResourceUnavailableException { + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + vpnConnection.setState(State.Pending); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + Site2SiteVpnServiceProvider provider = mockVpcVirtualRouterProvider(); + when(provider.startSite2SiteVpn(any(Site2SiteVpnConnection.class))) + .thenThrow(new InvalidParameterValueException("unsupported VPN policy")); + when(_vpcMgr.applyStaticRouteForVpcVpnIfNeeded(anyLong(), anyBoolean())).thenReturn(true); + + assertThrows(InvalidParameterValueException.class, + () -> site2SiteVpnManager.startVpnConnection(VPN_CONNECTION_ID)); + + assertEquals(State.Error, vpnConnection.getState()); + verify(_vpnConnectionDao, org.mockito.Mockito.atLeastOnce()).persist(vpnConnection); + } + + @Test + public void testGatewayLifecycleUsesPersistedProviderWhenOfferingChanges() throws ResourceUnavailableException { + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + Site2SiteVpnServiceProvider originalProvider = mockVpcVirtualRouterProvider(); + Site2SiteVpnServiceProvider replacementProvider = mock(Site2SiteVpnServiceProvider.class, + Mockito.withSettings().extraInterfaces(NetworkElement.class)); + when(((NetworkElement) replacementProvider).getProvider()).thenReturn(Network.Provider.Nsx); + when(_vpcMgr.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, Network.Provider.Nsx)) + .thenReturn(true); + when(originalProvider.ownsVpnGateway(vpnGateway)).thenReturn(true); + when(originalProvider.startSite2SiteVpn(vpnConnection)).thenReturn(true); + when(_vpnConnectionDao.persist(any(Site2SiteVpnConnectionVO.class))).thenReturn(vpnConnection); + + site2SiteVpnManager.startVpnConnection(VPN_CONNECTION_ID); + + verify(originalProvider).startSite2SiteVpn(vpnConnection); + verify(replacementProvider, never()).startSite2SiteVpn(any(Site2SiteVpnConnection.class)); + } + @Test(expected = InvalidParameterValueException.class) public void testStartVpnConnectionWrongState() throws ResourceUnavailableException { when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); @@ -607,10 +774,9 @@ public void testResetVpnConnectionSuccess() throws ResourceUnavailableException vpnConnection.setState(State.Connected); when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); - Site2SiteVpnServiceProvider provider = mock(Site2SiteVpnServiceProvider.class); + Site2SiteVpnServiceProvider provider = mockVpcVirtualRouterProvider(); when(provider.stopSite2SiteVpn(any(Site2SiteVpnConnection.class))).thenReturn(true); when(provider.startSite2SiteVpn(any(Site2SiteVpnConnection.class))).thenReturn(true); - when(_s2sProviders.iterator()).thenReturn(List.of(provider).iterator()); when(_vpnConnectionDao.persist(any(Site2SiteVpnConnectionVO.class))).thenReturn(vpnConnection); when(_vpcMgr.applyStaticRouteForVpcVpnIfNeeded(anyLong(), anyBoolean())).thenReturn(true); @@ -633,6 +799,7 @@ public void testCleanupVpnConnectionByVpc() { public void testCleanupVpnGatewayByVpc() { when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(vpnGateway); when(_vpnConnectionDao.listByVpnGatewayId(VPN_GATEWAY_ID)).thenReturn(new ArrayList<>()); + mockVpcVirtualRouterProvider(); boolean result = site2SiteVpnManager.cleanupVpnGatewayByVpc(VPC_ID); @@ -654,6 +821,8 @@ public void testCleanupVpnGatewayByVpcNotFound() { public void testGetConnectionsForRouter() { DomainRouterVO router = mock(DomainRouterVO.class); when(router.getVpcId()).thenReturn(VPC_ID); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(vpnGateway); + mockVpcVirtualRouterProvider(); when(_vpnConnectionDao.listByVpcId(VPC_ID)).thenReturn(List.of(vpnConnection)); List result = site2SiteVpnManager.getConnectionsForRouter(router); @@ -695,9 +864,8 @@ public void testReconnectDisconnectedVpnByVpc() throws ResourceUnavailableExcept when(_customerGatewayDao.findById(CUSTOMER_GATEWAY_ID)).thenReturn(customerGateway); when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(conn); when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); - Site2SiteVpnServiceProvider provider = mock(Site2SiteVpnServiceProvider.class); + Site2SiteVpnServiceProvider provider = mockVpcVirtualRouterProvider(); when(provider.startSite2SiteVpn(any(Site2SiteVpnConnection.class))).thenReturn(true); - when(_s2sProviders.iterator()).thenReturn(List.of(provider).iterator()); when(_vpnConnectionDao.persist(any(Site2SiteVpnConnectionVO.class))).thenReturn(conn); when(_vpcMgr.applyStaticRouteForVpcVpnIfNeeded(anyLong(), anyBoolean())).thenReturn(true); From 98cd4ed6f47fd02d79675b3a2adef0f9a1776b2c Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:18:14 +0100 Subject: [PATCH 2/8] NSX: harden native VPN lifecycle operations --- .../user/vpn/DeleteVpnConnectionCmd.java | 16 + .../command/user/vpn/DeleteVpnGatewayCmd.java | 11 + .../user/vpn/ResetVpnConnectionCmd.java | 16 + .../vpn/VpnConnectionLifecycleCmdTest.java | 142 +++++++ .../cloudstack/resource/NsxResource.java | 15 +- .../cloudstack/service/NsxApiClient.java | 184 +++++++-- .../apache/cloudstack/service/NsxElement.java | 2 +- .../cloudstack/service/NsxServiceImpl.java | 45 +- .../cloudstack/resource/NsxResourceTest.java | 51 ++- .../cloudstack/service/NsxApiClientTest.java | 389 +++++++++++++++++- .../cloudstack/service/NsxElementTest.java | 29 +- .../service/NsxServiceImplTest.java | 85 ++++ .../vpn/RemoteAccessVpnManagerImpl.java | 54 ++- .../network/vpn/Site2SiteVpnManagerImpl.java | 210 +++++----- .../vpn/RemoteAccessVpnManagerImplTest.java | 137 ++++++ .../vpn/Site2SiteVpnManagerImplTest.java | 227 +++++++++- 16 files changed, 1417 insertions(+), 196 deletions(-) create mode 100644 api/src/test/java/org/apache/cloudstack/api/command/user/vpn/VpnConnectionLifecycleCmdTest.java diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/DeleteVpnConnectionCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/DeleteVpnConnectionCmd.java index b23e6c163020..a1bdf88ed3b4 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/DeleteVpnConnectionCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/DeleteVpnConnectionCmd.java @@ -29,6 +29,7 @@ import com.cloud.event.EventTypes; import com.cloud.exception.ResourceUnavailableException; import com.cloud.network.Site2SiteVpnConnection; +import com.cloud.network.Site2SiteVpnGateway; import com.cloud.user.Account; @APICommand(name = "deleteVpnConnection", description = "Delete site to site VPN connection", responseObject = SuccessResponse.class, entityType = {Site2SiteVpnConnection.class}, @@ -73,6 +74,21 @@ public String getEventType() { return EventTypes.EVENT_S2S_VPN_CONNECTION_DELETE; } + @Override + public String getSyncObjType() { + return BaseAsyncCmd.vpcSyncObject; + } + + @Override + public Long getSyncObjId() { + Site2SiteVpnConnection connection = _entityMgr.findById(Site2SiteVpnConnection.class, id); + if (connection == null) { + return null; + } + Site2SiteVpnGateway gateway = _s2sVpnService.getVpnGateway(connection.getVpnGatewayId()); + return gateway == null ? null : gateway.getVpcId(); + } + @Override public void execute() { try { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/DeleteVpnGatewayCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/DeleteVpnGatewayCmd.java index bfea59a3e6f3..0b82ae6c38e5 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/DeleteVpnGatewayCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/DeleteVpnGatewayCmd.java @@ -72,6 +72,17 @@ public String getEventType() { return EventTypes.EVENT_S2S_VPN_GATEWAY_DELETE; } + @Override + public String getSyncObjType() { + return BaseAsyncCmd.vpcSyncObject; + } + + @Override + public Long getSyncObjId() { + Site2SiteVpnGateway gateway = _entityMgr.findById(Site2SiteVpnGateway.class, id); + return gateway == null ? null : gateway.getVpcId(); + } + @Override public void execute() { boolean result = false; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/ResetVpnConnectionCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/ResetVpnConnectionCmd.java index f681c8cce182..198208deb131 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/ResetVpnConnectionCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vpn/ResetVpnConnectionCmd.java @@ -30,6 +30,7 @@ import com.cloud.event.EventTypes; import com.cloud.exception.ResourceUnavailableException; import com.cloud.network.Site2SiteVpnConnection; +import com.cloud.network.Site2SiteVpnGateway; import com.cloud.user.Account; @APICommand(name = "resetVpnConnection", description = "Reset site to site VPN connection", responseObject = Site2SiteVpnConnectionResponse.class, entityType = {Site2SiteVpnConnection.class}, @@ -91,6 +92,21 @@ public String getEventType() { return EventTypes.EVENT_S2S_VPN_CONNECTION_RESET; } + @Override + public String getSyncObjType() { + return BaseAsyncCmd.vpcSyncObject; + } + + @Override + public Long getSyncObjId() { + Site2SiteVpnConnection connection = _entityMgr.findById(Site2SiteVpnConnection.class, id); + if (connection == null) { + return null; + } + Site2SiteVpnGateway gateway = _s2sVpnService.getVpnGateway(connection.getVpnGatewayId()); + return gateway == null ? null : gateway.getVpcId(); + } + @Override public void execute() { try { diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/vpn/VpnConnectionLifecycleCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/vpn/VpnConnectionLifecycleCmdTest.java new file mode 100644 index 000000000000..fa8964feec3a --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/vpn/VpnConnectionLifecycleCmdTest.java @@ -0,0 +1,142 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.user.vpn; + +import com.cloud.network.Site2SiteVpnConnection; +import com.cloud.network.Site2SiteVpnGateway; +import com.cloud.network.vpn.Site2SiteVpnService; +import com.cloud.utils.db.EntityManager; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class VpnConnectionLifecycleCmdTest { + + private static final Long CONNECTION_ID = 1L; + private static final Long VPN_GATEWAY_ID = 2L; + private static final Long VPC_ID = 3L; + + @Mock + private EntityManager entityManager; + @Mock + private Site2SiteVpnService vpnService; + @Mock + private Site2SiteVpnConnection connection; + @Mock + private Site2SiteVpnGateway gateway; + + private ResetVpnConnectionCmd resetCmd; + private DeleteVpnConnectionCmd deleteCmd; + private DeleteVpnGatewayCmd deleteGatewayCmd; + + @Before + public void setUp() { + resetCmd = new ResetVpnConnectionCmd(); + resetCmd._entityMgr = entityManager; + resetCmd._s2sVpnService = vpnService; + ReflectionTestUtils.setField(resetCmd, "id", CONNECTION_ID); + + deleteCmd = new DeleteVpnConnectionCmd(); + deleteCmd._entityMgr = entityManager; + deleteCmd._s2sVpnService = vpnService; + ReflectionTestUtils.setField(deleteCmd, "id", CONNECTION_ID); + + deleteGatewayCmd = new DeleteVpnGatewayCmd(); + deleteGatewayCmd._entityMgr = entityManager; + ReflectionTestUtils.setField(deleteGatewayCmd, "id", VPN_GATEWAY_ID); + } + + @Test + public void testResetUsesVpcSynchronization() { + configureExistingConnection(); + + assertEquals(BaseAsyncCmd.vpcSyncObject, resetCmd.getSyncObjType()); + assertEquals(VPC_ID, resetCmd.getSyncObjId()); + } + + @Test + public void testDeleteUsesVpcSynchronization() { + configureExistingConnection(); + + assertEquals(BaseAsyncCmd.vpcSyncObject, deleteCmd.getSyncObjType()); + assertEquals(VPC_ID, deleteCmd.getSyncObjId()); + } + + @Test + public void testDeleteGatewayUsesVpcSynchronization() { + when(entityManager.findById(Site2SiteVpnGateway.class, VPN_GATEWAY_ID)).thenReturn(gateway); + when(gateway.getVpcId()).thenReturn(VPC_ID); + + assertEquals(BaseAsyncCmd.vpcSyncObject, deleteGatewayCmd.getSyncObjType()); + assertEquals(VPC_ID, deleteGatewayCmd.getSyncObjId()); + } + + @Test + public void testResetWithMissingConnectionHasNoSynchronizationId() { + when(entityManager.findById(Site2SiteVpnConnection.class, CONNECTION_ID)).thenReturn(null); + + assertNull(resetCmd.getSyncObjId()); + } + + @Test + public void testDeleteWithMissingConnectionHasNoSynchronizationId() { + when(entityManager.findById(Site2SiteVpnConnection.class, CONNECTION_ID)).thenReturn(null); + + assertNull(deleteCmd.getSyncObjId()); + } + + @Test + public void testDeleteGatewayWithMissingGatewayHasNoSynchronizationId() { + when(entityManager.findById(Site2SiteVpnGateway.class, VPN_GATEWAY_ID)).thenReturn(null); + + assertNull(deleteGatewayCmd.getSyncObjId()); + } + + @Test + public void testResetWithMissingGatewayHasNoSynchronizationId() { + when(entityManager.findById(Site2SiteVpnConnection.class, CONNECTION_ID)).thenReturn(connection); + when(connection.getVpnGatewayId()).thenReturn(VPN_GATEWAY_ID); + when(vpnService.getVpnGateway(VPN_GATEWAY_ID)).thenReturn(null); + + assertNull(resetCmd.getSyncObjId()); + } + + @Test + public void testDeleteWithMissingGatewayHasNoSynchronizationId() { + when(entityManager.findById(Site2SiteVpnConnection.class, CONNECTION_ID)).thenReturn(connection); + when(connection.getVpnGatewayId()).thenReturn(VPN_GATEWAY_ID); + when(vpnService.getVpnGateway(VPN_GATEWAY_ID)).thenReturn(null); + + assertNull(deleteCmd.getSyncObjId()); + } + + private void configureExistingConnection() { + when(entityManager.findById(Site2SiteVpnConnection.class, CONNECTION_ID)).thenReturn(connection); + when(connection.getVpnGatewayId()).thenReturn(VPN_GATEWAY_ID); + when(vpnService.getVpnGateway(VPN_GATEWAY_ID)).thenReturn(gateway); + when(gateway.getVpcId()).thenReturn(VPC_ID); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java index a2e34a954798..606a394569bb 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java @@ -586,7 +586,7 @@ private NsxAnswer executeRequest(CreateNsxVpnConnectionCommand cmd) { cmd.getZoneId(), cmd.getVpcId(), true); Object lock = getVpnTier1Lock(tier1GatewayName); synchronized (lock) { - boolean sessionCreated = false; + NsxApiClient.VpnSessionProvisioningResult provisioningResult = null; try { // The requested VTI /30 is derived from the connection id. Fail closed when another // session already owns its local address; silently choosing a different pair would make @@ -598,23 +598,30 @@ private NsxAnswer executeRequest(CreateNsxVpnConnectionCommand cmd) { cmd.getVtiLocalIp(), cmd.getConnectionUuid(), tier1GatewayName)); } Pair vtiAddresses = new Pair<>(cmd.getVtiLocalIp(), cmd.getVtiPeerIp()); - nsxApiClient.createRouteBasedVpnSession(tier1GatewayName, cmd.getConnectionUuid(), cmd.getPeerAddress(), + provisioningResult = nsxApiClient.createRouteBasedVpnSession(tier1GatewayName, cmd.getConnectionUuid(), cmd.getPeerAddress(), cmd.getPsk(), cmd.getIkePolicy(), cmd.getEspPolicy(), cmd.getIkeLifetime(), cmd.getEspLifetime(), cmd.isDpdEnabled(), cmd.getIkeVersion(), cmd.isPassive(), vtiAddresses.first(), cmd.getVtiPrefixLength()); - sessionCreated = true; nsxApiClient.addVpnConnectionRoutes(tier1GatewayName, cmd.getConnectionUuid(), cmd.getPeerCidrs(), vtiAddresses.second(), cmd.getVpcCidr()); // Applied here as well so that VPN gateways created before the exemptions existed, or whose // tier-1 gained a source NAT rule afterwards, are corrected without recreating the gateway nsxApiClient.ensureVpnNatExemptions(tier1GatewayName, cmd.getLocalEndpointIp()); + nsxApiClient.updateVpnConnectionState(tier1GatewayName, cmd.getConnectionUuid(), true); } catch (Exception e) { - if (sessionCreated) { + if (provisioningResult == NsxApiClient.VpnSessionProvisioningResult.CREATED) { try { nsxApiClient.rollbackVpnConnection(tier1GatewayName, cmd.getConnectionUuid()); } catch (Exception rollbackException) { logger.warn("Failed to roll back the partially created NSX VPN connection {}: {}", cmd.getConnectionUuid(), rollbackException.getMessage()); } + } else if (provisioningResult == NsxApiClient.VpnSessionProvisioningResult.PREEXISTING) { + try { + nsxApiClient.updateVpnConnectionState(tier1GatewayName, cmd.getConnectionUuid(), false); + } catch (Exception compensationException) { + logger.warn("Failed to disable the pre-existing NSX VPN connection {} after provisioning failed: {}", + cmd.getConnectionUuid(), compensationException.getMessage()); + } } logger.error(String.format("Failed to create the NSX VPN connection %s on tier-1 gateway %s for VPC %s: %s", cmd.getConnectionUuid(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java index 3250784e3d71..fa1f94813a9d 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java @@ -128,6 +128,7 @@ import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.stream.Collectors; import static java.util.stream.Collectors.toSet; @@ -197,6 +198,11 @@ public class NsxApiClient { protected static final String VPN_SESSION_STATUS_UNKNOWN = "UNKNOWN"; protected static final String VPN_SESSION_STATUS_NOT_FOUND = "NOT_FOUND"; + public enum VpnSessionProvisioningResult { + CREATED, + PREEXISTING + } + private enum PoolAllocation { ROUTING, LB_SMALL, LB_MEDIUM, LB_LARGE, LB_XLARGE } private enum HAMode { ACTIVE_STANDBY, ACTIVE_ACTIVE } @@ -1522,23 +1528,29 @@ public void deleteVpnService(String tier1GatewayName) { restoreSourceNatRuleSequence(tier1GatewayName); } - public void createRouteBasedVpnSession(String tier1GatewayName, String connectionUuid, String peerAddress, - String psk, String ikePolicy, String espPolicy, Long ikeLifetime, - Long espLifetime, boolean dpdEnabled, String ikeVersion, boolean passive, - String vtiLocalIp, int vtiPrefixLength) { - // NSX reaps deleted objects asynchronously and rejects a create under a path that is still - // marked for deletion, which happens when a connection is recreated shortly after teardown + public VpnSessionProvisioningResult createRouteBasedVpnSession(String tier1GatewayName, String connectionUuid, + String peerAddress, String psk, String ikePolicy, + String espPolicy, Long ikeLifetime, Long espLifetime, + boolean dpdEnabled, String ikeVersion, boolean passive, + String vtiLocalIp, int vtiPrefixLength) { + return retryWhileMarkedForDeletion(connectionUuid, () -> doCreateRouteBasedVpnSession(tier1GatewayName, + connectionUuid, peerAddress, psk, ikePolicy, espPolicy, ikeLifetime, espLifetime, dpdEnabled, + ikeVersion, passive, vtiLocalIp, vtiPrefixLength)); + } + + /** + * NSX reaps deleted policy objects asynchronously and rejects a create under a path that is still + * marked for deletion. All VPN objects use deterministic IDs and PATCH/upsert semantics, so a + * retry of the complete idempotent operation is safe and preserves already-existing objects. + */ + private T retryWhileMarkedForDeletion(String connectionUuid, Supplier operation) { + CloudRuntimeException lastFailure = null; for (int attempt = 1; attempt <= VPN_MARKED_FOR_DELETION_RETRIES; attempt++) { try { - doCreateRouteBasedVpnSession(tier1GatewayName, connectionUuid, peerAddress, psk, ikePolicy, espPolicy, - ikeLifetime, espLifetime, dpdEnabled, ikeVersion, passive, vtiLocalIp, vtiPrefixLength); - return; + return operation.get(); } catch (CloudRuntimeException e) { + lastFailure = e; if (attempt == VPN_MARKED_FOR_DELETION_RETRIES || !isMarkedForDeletionError(e)) { - // All VPN objects use deterministic IDs and PATCH/upsert semantics. Do not - // delete profiles here: an ambiguous timeout may have followed a successful - // patch for an existing connection, and deleting those objects would destroy - // a live tunnel. The normal connection-delete path is the authoritative cleanup. throw e; } logger.info("A VPN object for connection {} is still being purged by NSX, retrying in {}s (attempt {}/{})", @@ -1551,16 +1563,18 @@ public void createRouteBasedVpnSession(String tier1GatewayName, String connectio } } } + throw lastFailure; } private boolean isMarkedForDeletionError(CloudRuntimeException e) { return e.getMessage() != null && e.getMessage().contains("marked for deletion"); } - private void doCreateRouteBasedVpnSession(String tier1GatewayName, String connectionUuid, String peerAddress, - String psk, String ikePolicy, String espPolicy, Long ikeLifetime, - Long espLifetime, boolean dpdEnabled, String ikeVersion, boolean passive, - String vtiLocalIp, int vtiPrefixLength) { + private VpnSessionProvisioningResult doCreateRouteBasedVpnSession(String tier1GatewayName, String connectionUuid, + String peerAddress, String psk, String ikePolicy, + String espPolicy, Long ikeLifetime, Long espLifetime, + boolean dpdEnabled, String ikeVersion, boolean passive, + String vtiLocalIp, int vtiPrefixLength) { String vpnServiceName = getVpnServiceName(tier1GatewayName); String localEndpointName = getVpnLocalEndpointName(vpnServiceName); String sessionName = getVpnSessionName(connectionUuid); @@ -1573,6 +1587,11 @@ private void doCreateRouteBasedVpnSession(String tier1GatewayName, String connec IpsecVpnDpdProfiles dpdProfiles = (IpsecVpnDpdProfiles) nsxService.apply(IpsecVpnDpdProfiles.class); Sessions sessions = (Sessions) nsxService.apply(Sessions.class); boolean sessionExisted = isVpnSessionPresent(sessions, tier1GatewayName, vpnServiceName, sessionName); + if (sessionExisted) { + // Disable the existing session before replacing any referenced profiles so a failed + // reconciliation cannot leave an active tunnel using a partially updated policy. + updateVpnConnectionState(tier1GatewayName, connectionUuid, false); + } boolean ikeProfileExisted = isVpnIkeProfilePresent(ikeProfiles, ikeProfileName); boolean espProfileExisted = isVpnTunnelProfilePresent(espProfiles, espProfileName); boolean dpdProfileExisted = isVpnDpdProfilePresent(dpdProfiles, dpdProfileName); @@ -1626,7 +1645,7 @@ private void doCreateRouteBasedVpnSession(String tier1GatewayName, String connec RouteBasedIPSecVpnSession session = new RouteBasedIPSecVpnSession.Builder() .setId(sessionName) .setDisplayName(sessionName) - .setEnabled(true) + .setEnabled(false) .setAuthenticationMode(IPSecVpnSession.AUTHENTICATION_MODE_PSK) .setPsk(psk) .setPeerAddress(peerAddress) @@ -1640,6 +1659,8 @@ private void doCreateRouteBasedVpnSession(String tier1GatewayName, String connec .setTunnelInterfaces(List.of(tunnelInterface)) .build(); sessions.patch(tier1GatewayName, vpnServiceName, sessionName, session); + return sessionExisted ? VpnSessionProvisioningResult.PREEXISTING + : VpnSessionProvisioningResult.CREATED; } catch (RuntimeException e) { if (!sessionExisted) { boolean sessionRemoved = deleteVpnSessionAfterCreateFailure(sessions, tier1GatewayName, @@ -1749,15 +1770,24 @@ private void deleteVpnProfileAfterCreateFailure(Runnable deleteAction, String pr public void addVpnConnectionRoutes(String tier1GatewayName, String connectionUuid, List peerCidrs, String vtiPeerIp, String vpcCidr) { + retryWhileMarkedForDeletion(connectionUuid, () -> { + doAddVpnConnectionRoutes(tier1GatewayName, connectionUuid, peerCidrs, vtiPeerIp, vpcCidr); + return null; + }); + } + + private void doAddVpnConnectionRoutes(String tier1GatewayName, String connectionUuid, List peerCidrs, + String vtiPeerIp, String vpcCidr) { try { - deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid)); - deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid)); com.vmware.nsx_policy.infra.tier_1s.StaticRoutes staticRoutesService = (com.vmware.nsx_policy.infra.tier_1s.StaticRoutes) nsxService.apply(com.vmware.nsx_policy.infra.tier_1s.StaticRoutes.class); NatRules natService = (NatRules) nsxService.apply(NatRules.class); + Set desiredRouteIds = new HashSet<>(); + Set desiredNoSnatRuleIds = new HashSet<>(); for (int i = 0; i < peerCidrs.size(); i++) { String peerCidr = peerCidrs.get(i); String routeName = getVpnStaticRouteName(connectionUuid, i); + desiredRouteIds.add(routeName); com.vmware.nsx_policy.model.StaticRoutes staticRoute = new com.vmware.nsx_policy.model.StaticRoutes.Builder() .setId(routeName) .setDisplayName(routeName) @@ -1769,6 +1799,7 @@ public void addVpnConnectionRoutes(String tier1GatewayName, String connectionUui // Route-based VPN does not bypass NAT: without a NO_SNAT rule the tier-1 match-any // SNAT would rewrite VPC-to-remote traffic before it enters the tunnel String noSnatRuleName = getVpnNoSnatRuleName(connectionUuid, i); + desiredNoSnatRuleIds.add(noSnatRuleName); PolicyNatRule noSnatRule = new PolicyNatRule.Builder() .setId(noSnatRuleName) .setDisplayName(noSnatRuleName) @@ -1780,6 +1811,12 @@ public void addVpnConnectionRoutes(String tier1GatewayName, String connectionUui .build(); natService.patch(tier1GatewayName, NatId.USER.name(), noSnatRuleName, noSnatRule); } + // Keep existing routes and exemptions in place until every desired object has been + // accepted. This makes an idempotent retry non-destructive if an NSX PATCH fails. + deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid), + desiredRouteIds); + deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid), + desiredNoSnatRuleIds); } catch (Error error) { ApiError ae = error.getData()._convertTo(ApiError.class); String msg = String.format("Failed to add the routes for NSX IPSec VPN connection %s on tier-1 gateway %s, due to: %s", @@ -1827,16 +1864,37 @@ public void updateVpnConnectionState(String tier1GatewayName, String connectionU String sessionName = getVpnSessionName(connectionUuid); try { Sessions sessions = (Sessions) nsxService.apply(Sessions.class); - RouteBasedIPSecVpnSession update = new RouteBasedIPSecVpnSession.Builder() - .setId(sessionName) - .setEnabled(enabled) - .build(); - sessions.patch(tier1GatewayName, vpnServiceName, sessionName, update); - if (!enabled) { - deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid)); - deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid)); + Structure current = sessions.showsensitivedata(tier1GatewayName, vpnServiceName, sessionName); + if (current == null) { + throw new CloudRuntimeException(String.format( + "NSX returned no data for IPSec VPN session %s on tier-1 gateway %s", + sessionName, tier1GatewayName)); + } + if (!current._hasTypeNameOf(RouteBasedIPSecVpnSession.class)) { + throw new CloudRuntimeException(String.format( + "IPSec VPN session %s on tier-1 gateway %s is not route-based", + sessionName, tier1GatewayName)); + } + RouteBasedIPSecVpnSession update = current._convertTo(RouteBasedIPSecVpnSession.class); + if (update.getRevision() == null) { + throw new CloudRuntimeException(String.format( + "NSX returned no revision for IPSec VPN session %s on tier-1 gateway %s", + sessionName, tier1GatewayName)); } + if (IPSecVpnSession.AUTHENTICATION_MODE_PSK.equals(update.getAuthenticationMode()) + && update.getPsk() == null) { + throw new CloudRuntimeException(String.format( + "NSX did not return sensitive authentication data for IPSec VPN session %s on tier-1 gateway %s", + sessionName, tier1GatewayName)); + } + update.setEnabled(enabled); + sessions.update(tier1GatewayName, vpnServiceName, sessionName, update); } catch (NotFound e) { + if (enabled) { + throw new CloudRuntimeException(String.format( + "Cannot enable NSX IPSec VPN session %s on tier-1 gateway %s because it does not exist", + sessionName, tier1GatewayName), e); + } logger.debug("The VPN session {} no longer exists on tier-1 gateway {}, skipping state update", sessionName, tier1GatewayName); } catch (Error error) { @@ -1845,6 +1903,10 @@ public void updateVpnConnectionState(String tier1GatewayName, String connectionU "Failed to update the state of NSX IPSec VPN session %s on tier-1 gateway %s, due to: %s", sessionName, tier1GatewayName, ae.getErrorMessage()), error); } + if (!enabled) { + deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid)); + deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid)); + } } /** @@ -1856,6 +1918,11 @@ public void rollbackVpnConnection(String tier1GatewayName, String connectionUuid } private void deleteVpnStaticRoutesByPrefix(String tier1GatewayName, String routeNamePrefix) { + deleteVpnStaticRoutesByPrefix(tier1GatewayName, routeNamePrefix, Set.of()); + } + + private void deleteVpnStaticRoutesByPrefix(String tier1GatewayName, String routeNamePrefix, + Set retainedRouteIds) { com.vmware.nsx_policy.infra.tier_1s.StaticRoutes staticRoutesService = (com.vmware.nsx_policy.infra.tier_1s.StaticRoutes) nsxService.apply(com.vmware.nsx_policy.infra.tier_1s.StaticRoutes.class); try { @@ -1873,12 +1940,17 @@ private void deleteVpnStaticRoutesByPrefix(String tier1GatewayName, String route if (CollectionUtils.isEmpty(staticRoutes)) { return; } + RuntimeException failure = null; for (com.vmware.nsx_policy.model.StaticRoutes staticRoute : staticRoutes) { - if (staticRoute.getId() != null && staticRoute.getId().startsWith(routeNamePrefix)) { + if (staticRoute.getId() != null && staticRoute.getId().startsWith(routeNamePrefix) + && !retainedRouteIds.contains(staticRoute.getId())) { logger.debug("Removing the VPN static route {} from tier-1 gateway {}", staticRoute.getId(), tier1GatewayName); - staticRoutesService.delete(tier1GatewayName, staticRoute.getId()); + String routeId = staticRoute.getId(); + failure = runVpnCleanupStep(failure, String.format("static route %s", routeId), routeNamePrefix, + () -> deleteVpnStaticRoute(staticRoutesService, tier1GatewayName, routeId)); } } + throwVpnPrefixCleanupFailure(failure, "static routes", routeNamePrefix, tier1GatewayName); } catch (NotFound e) { logger.debug("No static routes matching the prefix {} are left on tier-1 gateway {}, skipping deletion", routeNamePrefix, tier1GatewayName); @@ -1891,7 +1963,27 @@ private void deleteVpnStaticRoutesByPrefix(String tier1GatewayName, String route } } + private void deleteVpnStaticRoute(com.vmware.nsx_policy.infra.tier_1s.StaticRoutes staticRoutesService, + String tier1GatewayName, String routeId) { + try { + staticRoutesService.delete(tier1GatewayName, routeId); + } catch (NotFound e) { + logger.debug("The VPN static route {} on tier-1 gateway {} no longer exists, skipping deletion", + routeId, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + throw new CloudRuntimeException(String.format( + "Failed to delete the VPN static route %s on tier-1 gateway %s, due to: %s", + routeId, tier1GatewayName, ae.getErrorMessage()), error); + } + } + private void deleteVpnNoSnatRulesByPrefix(String tier1GatewayName, String ruleNamePrefix) { + deleteVpnNoSnatRulesByPrefix(tier1GatewayName, ruleNamePrefix, Set.of()); + } + + private void deleteVpnNoSnatRulesByPrefix(String tier1GatewayName, String ruleNamePrefix, + Set retainedRuleIds) { NatRules natService = (NatRules) nsxService.apply(NatRules.class); try { List natRules = PagedFetcher.withPageFetcher( @@ -1907,12 +1999,17 @@ private void deleteVpnNoSnatRulesByPrefix(String tier1GatewayName, String ruleNa if (CollectionUtils.isEmpty(natRules)) { return; } + RuntimeException failure = null; for (PolicyNatRule natRule : natRules) { - if (natRule.getId() != null && natRule.getId().startsWith(ruleNamePrefix)) { + if (natRule.getId() != null && natRule.getId().startsWith(ruleNamePrefix) + && !retainedRuleIds.contains(natRule.getId())) { logger.debug("Removing the VPN NO_SNAT rule {} from tier-1 gateway {}", natRule.getId(), tier1GatewayName); - natService.delete(tier1GatewayName, NatId.USER.name(), natRule.getId()); + String ruleId = natRule.getId(); + failure = runVpnCleanupStep(failure, String.format("NO_SNAT rule %s", ruleId), ruleNamePrefix, + () -> deleteVpnNoSnatRule(natService, tier1GatewayName, ruleId)); } } + throwVpnPrefixCleanupFailure(failure, "NO_SNAT rules", ruleNamePrefix, tier1GatewayName); } catch (NotFound e) { logger.debug("No NO_SNAT rules matching the prefix {} are left on tier-1 gateway {}, skipping deletion", ruleNamePrefix, tier1GatewayName); @@ -1925,6 +2022,29 @@ private void deleteVpnNoSnatRulesByPrefix(String tier1GatewayName, String ruleNa } } + private void deleteVpnNoSnatRule(NatRules natService, String tier1GatewayName, String ruleId) { + try { + natService.delete(tier1GatewayName, NatId.USER.name(), ruleId); + } catch (NotFound e) { + logger.debug("The VPN NO_SNAT rule {} on tier-1 gateway {} no longer exists, skipping deletion", + ruleId, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + throw new CloudRuntimeException(String.format( + "Failed to delete the VPN NO_SNAT rule %s on tier-1 gateway %s, due to: %s", + ruleId, tier1GatewayName, ae.getErrorMessage()), error); + } + } + + private void throwVpnPrefixCleanupFailure(RuntimeException failure, String resource, String prefix, + String tier1GatewayName) { + if (failure != null) { + throw new CloudRuntimeException(String.format( + "Failed to remove all NSX VPN %s matching prefix %s on tier-1 gateway %s", + resource, prefix, tier1GatewayName), failure); + } + } + private void deleteVpnSessionProfiles(String connectionUuid) { RuntimeException failure = null; failure = runVpnCleanupStep(failure, "IKE profile", connectionUuid, diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java index a5bdb659682d..08149d5f933e 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java @@ -1000,7 +1000,7 @@ protected boolean isVpnProvidedByNsx(Vpc vpc) { } protected boolean isVpnProvidedByNsx(Vpc vpc, Site2SiteVpnGateway gateway) { - return vpc != null && (isVpnProvidedByNsx(vpc) || ownsVpnGateway(gateway)); + return vpc != null && ownsVpnGateway(gateway); } @Override diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java index 3f0fed6098cb..17e0513f01df 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java @@ -70,10 +70,8 @@ import com.cloud.network.nsx.NsxService; import com.cloud.network.nsx.NsxVpnGatewayResult; import com.cloud.network.vpc.Vpc; -import com.cloud.network.vpc.VpcManager; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; -import com.cloud.network.vpc.dao.VpcOfferingServiceMapDao; import com.cloud.utils.component.ManagerBase; import com.cloud.utils.concurrency.NamedThreadFactory; import com.cloud.utils.exception.CloudRuntimeException; @@ -102,10 +100,6 @@ public class NsxServiceImpl extends ManagerBase implements NsxService, Configura @Inject VpcDao vpcDao; @Inject - VpcOfferingServiceMapDao vpcOfferingServiceMapDao; - @Inject - VpcManager vpcManager; - @Inject Site2SiteVpnConnectionDao site2SiteVpnConnectionDao; @Inject Site2SiteVpnGatewayDao site2SiteVpnGatewayDao; @@ -336,9 +330,9 @@ public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { } /** - * Every management server runs this poller over all NSX-provided VPN connections; the - * duplicate polling in a multi-server setup is tolerated, as state transitions are - * serialized by the row lock in transitionVpnConnectionState + * Every management server runs this poller over VPN connections whose gateway has persisted + * NSX ownership; duplicate polling in a multi-server setup is tolerated, as state transitions + * are serialized by the row lock in transitionVpnConnectionState */ protected class VpnStatusPollTask extends ManagedContextRunnable { @Override @@ -369,24 +363,15 @@ protected void runInContext() { } } - private boolean isVpnProvidedByNsx(Vpc vpc) { - if (vpcManager != null) { - return vpcManager.isProviderSupportServiceInVpc(vpc.getId(), Network.Service.Vpn, Network.Provider.Nsx); - } - return vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId( - Network.Service.Vpn.getName(), Network.Provider.Nsx.getName(), vpc.getVpcOfferingId()) != null; - } - private boolean isVpnProvidedByNsx(Vpc vpc, Site2SiteVpnGatewayVO vpnGateway) { - if (isVpnProvidedByNsx(vpc)) { - return true; - } - return userIpAddressDetailsDao != null + return vpc != null + && userIpAddressDetailsDao != null && vpnGateway != null && userIpAddressDetailsDao.findDetail(vpnGateway.getAddrId(), NsxElement.NSX_VPN_GATEWAY_IP_DETAIL) != null; } protected void pollVpnConnectionStatus(Site2SiteVpnConnectionVO connection, VpcVO vpc) { + Site2SiteVpnConnection.State observedState = connection.getState(); String status; try { status = getVpnConnectionStatus(vpc, connection.getUuid()); @@ -401,7 +386,7 @@ protected void pollVpnConnectionStatus(Site2SiteVpnConnectionVO connection, VpcV String title = String.format("Unable to poll the status of Site-to-site Vpn Connection %s", connection.getUuid()); String context = String.format( "The status of Site-to-site Vpn Connection %s on the NSX tier-1 gateway of VPC %s could not be polled %d consecutive times; its state %s is left unchanged", - connection.getUuid(), vpc.getName(), failures, connection.getState()); + connection.getUuid(), vpc.getName(), failures, observedState); logger.warn(context); alertManager.sendAlert(AlertManager.AlertType.ALERT_TYPE_DOMAIN_ROUTER, vpc.getZoneId(), null, title, context); vpnStatusPollFailures.remove(connection.getId()); @@ -414,12 +399,12 @@ protected void pollVpnConnectionStatus(Site2SiteVpnConnectionVO connection, VpcV } else if (VPN_SESSION_STATUS_DOWN.equals(status) || VPN_SESSION_STATUS_DEGRADED.equals(status)) { newState = Site2SiteVpnConnection.State.Disconnected; } else if (VPN_SESSION_STATUS_NOT_FOUND.equals(status)) { - if (connection.getState() == Site2SiteVpnConnection.State.Pending - || connection.getState() == Site2SiteVpnConnection.State.Connecting) { + if (observedState == Site2SiteVpnConnection.State.Pending + || observedState == Site2SiteVpnConnection.State.Connecting) { // the async connection job may still be creating the session on NSX return; } - if (connection.getState() == Site2SiteVpnConnection.State.Disconnected) { + if (observedState == Site2SiteVpnConnection.State.Disconnected) { // stop intentionally disables the session; a subsequent status lookup may report it // as absent while the connection remains a valid, stopped CloudStack resource return; @@ -430,11 +415,13 @@ protected void pollVpnConnectionStatus(Site2SiteVpnConnectionVO connection, VpcV logger.debug("NSX VPN connection {} of VPC {} reported the status {}, not transitioning the state", connection, vpc, status); return; } - transitionVpnConnectionState(connection, vpc, newState); + transitionVpnConnectionState(connection, vpc, observedState, newState); } - protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, Site2SiteVpnConnection.State newState) { - if (connection.getState() == newState) { + protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, + Site2SiteVpnConnection.State observedState, + Site2SiteVpnConnection.State newState) { + if (observedState == newState) { return; } Site2SiteVpnConnectionVO lock = site2SiteVpnConnectionDao.acquireInLockTable(connection.getId()); @@ -445,7 +432,7 @@ protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, try { Site2SiteVpnConnectionVO lockedConnection = site2SiteVpnConnectionDao.findById(connection.getId()); if (lockedConnection == null || !VPN_POLLED_STATES.contains(lockedConnection.getState()) - || lockedConnection.getState() == newState) { + || lockedConnection.getState() != observedState) { return; } Site2SiteVpnConnection.State oldState = lockedConnection.getState(); diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java index 24b94c1553d7..27790354f19a 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java @@ -52,6 +52,7 @@ import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; @@ -353,13 +354,16 @@ public void testCreateNsxVpnGatewayReportsPossibleResourceWhenRollbackFails() { } @Test - public void testCreateNsxVpnConnectionRollsBackAfterRouteFailure() { + public void testCreateNsxVpnConnectionRollsBackNewSessionAfterRouteFailure() { CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, 3L, "VPC01", "connection-uuid", "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, "10.1.0.0/16", "203.0.113.20"); when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyString())).thenReturn(Set.of()); + when(nsxApi.createRouteBasedVpnSession(anyString(), anyString(), anyString(), anyString(), anyString(), + anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyString(), anyInt())) + .thenReturn(NsxApiClient.VpnSessionProvisioningResult.CREATED); doThrow(new CloudRuntimeException("ERROR")).when(nsxApi).addVpnConnectionRoutes(anyString(), anyString(), anyList(), anyString(), anyString()); NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); @@ -368,6 +372,51 @@ public void testCreateNsxVpnConnectionRollsBackAfterRouteFailure() { verify(nsxApi).rollbackVpnConnection("D1-A2-Z1-V3", "connection-uuid"); } + @Test + public void testCreateNsxVpnConnectionDisablesPreexistingSessionAfterRouteFailure() { + CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, + 3L, "VPC01", "connection-uuid", "203.0.113.10", "psk", "aes256-sha256;modp2048", + "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, + List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, + "10.1.0.0/16", "203.0.113.20"); + when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyString())).thenReturn(Set.of()); + when(nsxApi.createRouteBasedVpnSession(anyString(), anyString(), anyString(), anyString(), anyString(), + anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyString(), anyInt())) + .thenReturn(NsxApiClient.VpnSessionProvisioningResult.PREEXISTING); + doThrow(new CloudRuntimeException("ERROR")).when(nsxApi).addVpnConnectionRoutes(anyString(), anyString(), + anyList(), anyString(), anyString()); + + NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); + + assertFalse(answer.getResult()); + verify(nsxApi, never()).rollbackVpnConnection(anyString(), anyString()); + verify(nsxApi).updateVpnConnectionState("D1-A2-Z1-V3", "connection-uuid", false); + } + + @Test + public void testCreateNsxVpnConnectionEnablesSessionAfterRoutesAndNatExemptions() { + CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, + 3L, "VPC01", "connection-uuid", "203.0.113.10", "psk", "aes256-sha256;modp2048", + "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, + List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, + "10.1.0.0/16", "203.0.113.20"); + when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyString())).thenReturn(Set.of()); + when(nsxApi.createRouteBasedVpnSession(anyString(), anyString(), anyString(), anyString(), anyString(), + anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyString(), anyInt())) + .thenReturn(NsxApiClient.VpnSessionProvisioningResult.CREATED); + + NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); + + assertTrue(answer.getResult()); + InOrder inOrder = Mockito.inOrder(nsxApi); + inOrder.verify(nsxApi).createRouteBasedVpnSession(anyString(), anyString(), anyString(), anyString(), anyString(), + anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyString(), anyInt()); + inOrder.verify(nsxApi).addVpnConnectionRoutes("D1-A2-Z1-V3", "connection-uuid", + List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16"); + inOrder.verify(nsxApi).ensureVpnNatExemptions("D1-A2-Z1-V3", "203.0.113.20"); + inOrder.verify(nsxApi).updateVpnConnectionState("D1-A2-Z1-V3", "connection-uuid", true); + } + @Test public void testCreateNsxVpnConnectionRejectsVtiCollisionBeforeCreate() { CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java index 6de675386df9..f046f5db75d6 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java @@ -41,7 +41,9 @@ import com.vmware.nsx_policy.model.Group; import com.vmware.nsx_policy.model.IPSecVpnDpdProfile; import com.vmware.nsx_policy.model.IPSecVpnIkeProfile; +import com.vmware.nsx_policy.model.IPSecVpnSession; import com.vmware.nsx_policy.model.IPSecVpnServiceListResult; +import com.vmware.nsx_policy.model.IPSecVpnTunnelInterface; import com.vmware.nsx_policy.model.IPSecVpnTunnelProfile; import com.vmware.nsx_policy.model.LBAppProfileListResult; import com.vmware.nsx_policy.model.LBIcmpMonitorProfile; @@ -57,6 +59,7 @@ import com.vmware.nsx_policy.model.StaticRoutesListResult; import com.vmware.nsx_policy.model.Tag; import com.vmware.nsx_policy.model.Tier1; +import com.vmware.nsx_policy.model.TunnelInterfaceIPSubnet; import com.vmware.vapi.bindings.Service; import com.vmware.vapi.bindings.Structure; import com.vmware.vapi.std.errors.Error; @@ -78,8 +81,10 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; @@ -467,7 +472,14 @@ public void testDeleteTier1GatewayRemovesVpnResourcesBeforeLocaleServices() { LocaleServices localeServices = Mockito.mock(LocaleServices.class); StaticRoutesListResult staticRoutesResult = Mockito.mock(StaticRoutesListResult.class); PolicyNatRuleListResult natRulesResult = Mockito.mock(PolicyNatRuleListResult.class); + PolicyNatRuleListResult remainingNatRulesResult = Mockito.mock(PolicyNatRuleListResult.class); IPSecVpnServiceListResult vpnServicesResult = Mockito.mock(IPSecVpnServiceListResult.class); + com.vmware.nsx_policy.model.StaticRoutes vpnStaticRoute = + Mockito.mock(com.vmware.nsx_policy.model.StaticRoutes.class); + com.vmware.nsx_policy.model.StaticRoutes operatorStaticRoute = + Mockito.mock(com.vmware.nsx_policy.model.StaticRoutes.class); + PolicyNatRule vpnNoSnatRule = Mockito.mock(PolicyNatRule.class); + PolicyNatRule operatorNatRule = Mockito.mock(PolicyNatRule.class); when(nsxService.apply(Tier1s.class)).thenReturn(tier1s); when(nsxService.apply(StaticRoutes.class)).thenReturn(staticRoutes); @@ -478,11 +490,16 @@ public void testDeleteTier1GatewayRemovesVpnResourcesBeforeLocaleServices() { when(staticRoutes.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) .thenReturn(staticRoutesResult); - when(staticRoutesResult.getResults()).thenReturn(List.of()); + when(vpnStaticRoute.getId()).thenReturn("cs-conn-connection-uuid-route0"); + when(operatorStaticRoute.getId()).thenReturn("operator-route"); + when(staticRoutesResult.getResults()).thenReturn(List.of(vpnStaticRoute, operatorStaticRoute)); when(natRules.list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) - .thenReturn(natRulesResult); - when(natRulesResult.getResults()).thenReturn(List.of()); + .thenReturn(natRulesResult, remainingNatRulesResult); + when(vpnNoSnatRule.getId()).thenReturn("cs-conn-connection-uuid-nosnat0"); + when(operatorNatRule.getId()).thenReturn("operator-nat-rule"); + when(natRulesResult.getResults()).thenReturn(List.of(vpnNoSnatRule, operatorNatRule)); + when(remainingNatRulesResult.getResults()).thenReturn(List.of(operatorNatRule)); when(vpnServices.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), eq(false), nullable(String.class))) .thenReturn(vpnServicesResult); @@ -493,15 +510,19 @@ public void testDeleteTier1GatewayRemovesVpnResourcesBeforeLocaleServices() { InOrder inOrder = Mockito.inOrder(staticRoutes, natRules, vpnServices, localeServices, tier1s); inOrder.verify(staticRoutes).list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); + inOrder.verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route0"); inOrder.verify(natRules).list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); - inOrder.verify(natRules).delete(eq(TIER_1_GATEWAY_NAME), anyString(), anyString()); + inOrder.verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat0"); + inOrder.verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "t1-vpn-le-nosnat"); inOrder.verify(vpnServices).list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), eq(false), nullable(String.class)); inOrder.verify(natRules).list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); + inOrder.verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "operator-nat-rule"); inOrder.verify(localeServices).delete(TIER_1_GATEWAY_NAME, "default"); inOrder.verify(tier1s).delete(TIER_1_GATEWAY_NAME); + verify(staticRoutes, never()).delete(TIER_1_GATEWAY_NAME, "operator-route"); } @Test @@ -533,12 +554,14 @@ public void testCreateRouteBasedVpnSessionRemovesSessionWhenPatchFails() { } @Test - public void testCreateRouteBasedVpnSessionDoesNotDeleteExistingSessionWhenPatchFails() { + public void testCreateRouteBasedVpnSessionLeavesExistingSessionDisabledWhenPatchFails() { IpsecVpnIkeProfiles ikeProfiles = Mockito.mock(IpsecVpnIkeProfiles.class); IpsecVpnTunnelProfiles tunnelProfiles = Mockito.mock(IpsecVpnTunnelProfiles.class); IpsecVpnDpdProfiles dpdProfiles = Mockito.mock(IpsecVpnDpdProfiles.class); Sessions sessions = Mockito.mock(Sessions.class); - Structure existingSession = Mockito.mock(Structure.class); + RouteBasedIPSecVpnSession existingSession = createCompleteVpnSession("secret-psk"); + StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); + NatRules natRules = Mockito.mock(NatRules.class); Structure errorData = Mockito.mock(Structure.class); ApiError apiError = new ApiError(); apiError.setErrorData(errorData); @@ -549,6 +572,9 @@ public void testCreateRouteBasedVpnSessionDoesNotDeleteExistingSessionWhenPatchF Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); Mockito.when(sessions.get(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) .thenReturn(existingSession); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + .thenReturn(existingSession); + mockEmptyVpnConnectionRouteLists(staticRoutes, natRules); Mockito.when(errorData._convertTo(ApiError.class)).thenReturn(apiError); doThrow(new Error(List.of(), errorData)).when(sessions).patch(anyString(), anyString(), anyString(), any(RouteBasedIPSecVpnSession.class)); @@ -561,6 +587,13 @@ public void testCreateRouteBasedVpnSessionDoesNotDeleteExistingSessionWhenPatchF verify(ikeProfiles, never()).delete(anyString()); verify(tunnelProfiles, never()).delete(anyString()); verify(dpdProfiles, never()).delete(anyString()); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Structure.class); + InOrder inOrder = Mockito.inOrder(sessions, ikeProfiles); + inOrder.verify(sessions).update(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), + eq("cs-conn-connection-uuid"), updateCaptor.capture()); + inOrder.verify(ikeProfiles).patch(eq("cs-conn-connection-uuid-ike"), any(IPSecVpnIkeProfile.class)); + RouteBasedIPSecVpnSession update = updateCaptor.getValue()._convertTo(RouteBasedIPSecVpnSession.class); + assertFalse(update.getEnabled()); } @Test @@ -623,6 +656,307 @@ public void testCreateRouteBasedVpnSessionPreservesPreExistingProfilesAfterFailu verify(dpdProfiles, never()).delete(anyString()); } + @Test + public void testCreateRouteBasedVpnSessionReportsWhetherSessionWasPreexisting() { + IpsecVpnIkeProfiles ikeProfiles = Mockito.mock(IpsecVpnIkeProfiles.class); + IpsecVpnTunnelProfiles tunnelProfiles = Mockito.mock(IpsecVpnTunnelProfiles.class); + IpsecVpnDpdProfiles dpdProfiles = Mockito.mock(IpsecVpnDpdProfiles.class); + Sessions sessions = Mockito.mock(Sessions.class); + Mockito.when(nsxService.apply(IpsecVpnIkeProfiles.class)).thenReturn(ikeProfiles); + Mockito.when(nsxService.apply(IpsecVpnTunnelProfiles.class)).thenReturn(tunnelProfiles); + Mockito.when(nsxService.apply(IpsecVpnDpdProfiles.class)).thenReturn(dpdProfiles); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + + assertEquals(NsxApiClient.VpnSessionProvisioningResult.CREATED, client.createRouteBasedVpnSession( + TIER_1_GATEWAY_NAME, "new-connection", "203.0.113.10", "psk", + "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, + true, "ikev2", false, "169.254.64.21", 30)); + + Mockito.when(sessions.get(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-existing-connection")) + .thenReturn(Mockito.mock(Structure.class)); + RouteBasedIPSecVpnSession existingSession = createCompleteVpnSession("secret-psk"); + existingSession.setId("cs-conn-existing-connection"); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-existing-connection")) + .thenReturn(existingSession); + StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); + NatRules natRules = Mockito.mock(NatRules.class); + mockEmptyVpnConnectionRouteLists(staticRoutes, natRules); + assertEquals(NsxApiClient.VpnSessionProvisioningResult.PREEXISTING, client.createRouteBasedVpnSession( + TIER_1_GATEWAY_NAME, "existing-connection", "203.0.113.10", "psk", + "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, + true, "ikev2", false, "169.254.64.25", 30)); + + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(RouteBasedIPSecVpnSession.class); + verify(sessions, Mockito.times(2)).patch(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), anyString(), sessionCaptor.capture()); + assertTrue(sessionCaptor.getAllValues().stream().noneMatch(RouteBasedIPSecVpnSession::getEnabled)); + ArgumentCaptor dpdProfileCaptor = ArgumentCaptor.forClass(IPSecVpnDpdProfile.class); + verify(dpdProfiles, Mockito.times(2)).patch(anyString(), dpdProfileCaptor.capture()); + assertTrue(dpdProfileCaptor.getAllValues().stream().allMatch(profile -> + IPSecVpnDpdProfile.DPD_PROBE_MODE_ON_DEMAND.equals(profile.getDpdProbeMode()) + && Long.valueOf(10L).equals(profile.getDpdProbeInterval()) + && Long.valueOf(10L).equals(profile.getRetryCount()))); + } + + @Test + public void testUpdateVpnConnectionStateUsesSensitiveFullReplace() { + Sessions sessions = Mockito.mock(Sessions.class); + StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); + NatRules natRules = Mockito.mock(NatRules.class); + RouteBasedIPSecVpnSession session = createCompleteVpnSession("secret-psk"); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + .thenReturn(session); + mockEmptyVpnConnectionRouteLists(staticRoutes, natRules); + + client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Structure.class); + InOrder inOrder = Mockito.inOrder(sessions, staticRoutes, natRules); + inOrder.verify(sessions).showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid"); + inOrder.verify(sessions).update(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), + eq("cs-conn-connection-uuid"), updateCaptor.capture()); + inOrder.verify(staticRoutes).list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); + inOrder.verify(natRules).list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); + RouteBasedIPSecVpnSession update = updateCaptor.getValue()._convertTo(RouteBasedIPSecVpnSession.class); + assertFalse(update.getEnabled()); + assertEquals("secret-psk", update.getPsk()); + assertEquals("203.0.113.10", update.getPeerAddress()); + assertEquals("/infra/tier-1s/t1/ipsec-vpn-services/t1-vpn/local-endpoints/t1-vpn-le", update.getLocalEndpointPath()); + assertEquals("/infra/ipsec-vpn-ike-profiles/ike", update.getIkeProfilePath()); + assertEquals("/infra/ipsec-vpn-tunnel-profiles/esp", update.getTunnelProfilePath()); + assertEquals("/infra/ipsec-vpn-dpd-profiles/dpd", update.getDpdProfilePath()); + assertEquals(Long.valueOf(7L), update.getRevision()); + assertEquals(1, update.getTunnelInterfaces().size()); + verify(sessions, never()).patch(anyString(), anyString(), anyString(), any(Structure.class)); + } + + @Test + public void testUpdateVpnConnectionStateDoesNotCleanupWhenPutFails() { + Sessions sessions = Mockito.mock(Sessions.class); + Structure errorData = Mockito.mock(Structure.class); + ApiError apiError = new ApiError(); + apiError.setErrorMessage("update failed"); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + .thenReturn(createCompleteVpnSession("secret-psk")); + Mockito.when(errorData._convertTo(ApiError.class)).thenReturn(apiError); + doThrow(new Error(List.of(), errorData)).when(sessions) + .update(anyString(), anyString(), anyString(), any(Structure.class)); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false)); + + assertFalse(exception.getMessage().contains("secret-psk")); + verify(nsxService, never()).apply(StaticRoutes.class); + verify(nsxService, never()).apply(NatRules.class); + } + + @Test + public void testUpdateVpnConnectionStateRejectsMissingSensitivePsk() { + Sessions sessions = Mockito.mock(Sessions.class); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + .thenReturn(createCompleteVpnSession(null)); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false)); + + assertTrue(exception.getMessage().contains("did not return sensitive authentication data")); + verify(sessions, never()).update(anyString(), anyString(), anyString(), any(Structure.class)); + verify(nsxService, never()).apply(StaticRoutes.class); + verify(nsxService, never()).apply(NatRules.class); + } + + @Test + public void testUpdateVpnConnectionStateRejectsNonRouteBasedSession() { + Sessions sessions = Mockito.mock(Sessions.class); + Structure session = Mockito.mock(Structure.class); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + .thenReturn(session); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false)); + + assertTrue(exception.getMessage().contains("is not route-based")); + verify(sessions, never()).update(anyString(), anyString(), anyString(), any(Structure.class)); + verify(nsxService, never()).apply(StaticRoutes.class); + verify(nsxService, never()).apply(NatRules.class); + } + + @Test + public void testUpdateVpnConnectionStateRejectsMissingRevision() { + Sessions sessions = Mockito.mock(Sessions.class); + RouteBasedIPSecVpnSession session = createCompleteVpnSession("secret-psk"); + session.setRevision(null); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + .thenReturn(session); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false)); + + assertTrue(exception.getMessage().contains("returned no revision")); + verify(sessions, never()).update(anyString(), anyString(), anyString(), any(Structure.class)); + verify(nsxService, never()).apply(StaticRoutes.class); + verify(nsxService, never()).apply(NatRules.class); + } + + @Test + public void testDisableMissingVpnSessionStillCleansStaleRoutesAndNat() { + Sessions sessions = Mockito.mock(Sessions.class); + StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); + NatRules natRules = Mockito.mock(NatRules.class); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + .thenThrow(new NotFound(null, null)); + mockEmptyVpnConnectionRouteLists(staticRoutes, natRules); + + client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false); + + verify(sessions, never()).update(anyString(), anyString(), anyString(), any(Structure.class)); + verify(staticRoutes).list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); + verify(natRules).list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); + } + + @Test + public void testEnableMissingVpnSessionFailsWithoutRouteCleanup() { + Sessions sessions = Mockito.mock(Sessions.class); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + .thenThrow(new NotFound(null, null)); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", true)); + + assertTrue(exception.getMessage().contains("because it does not exist")); + verify(sessions, never()).update(anyString(), anyString(), anyString(), any(Structure.class)); + verify(nsxService, never()).apply(StaticRoutes.class); + verify(nsxService, never()).apply(NatRules.class); + } + + @Test + public void testVpnRouteCleanupContinuesWhenIndividualObjectsAreAlreadyAbsent() { + Sessions sessions = Mockito.mock(Sessions.class); + StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); + NatRules natRules = Mockito.mock(NatRules.class); + StaticRoutesListResult routeList = Mockito.mock(StaticRoutesListResult.class); + PolicyNatRuleListResult ruleList = Mockito.mock(PolicyNatRuleListResult.class); + com.vmware.nsx_policy.model.StaticRoutes firstRoute = Mockito.mock(com.vmware.nsx_policy.model.StaticRoutes.class); + com.vmware.nsx_policy.model.StaticRoutes secondRoute = Mockito.mock(com.vmware.nsx_policy.model.StaticRoutes.class); + PolicyNatRule firstRule = Mockito.mock(PolicyNatRule.class); + PolicyNatRule secondRule = Mockito.mock(PolicyNatRule.class); + Mockito.when(firstRoute.getId()).thenReturn("cs-conn-connection-uuid-route0"); + Mockito.when(secondRoute.getId()).thenReturn("cs-conn-connection-uuid-route1"); + Mockito.when(firstRule.getId()).thenReturn("cs-conn-connection-uuid-nosnat0"); + Mockito.when(secondRule.getId()).thenReturn("cs-conn-connection-uuid-nosnat1"); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + .thenThrow(new NotFound(null, null)); + Mockito.when(nsxService.apply(StaticRoutes.class)).thenReturn(staticRoutes); + Mockito.when(staticRoutes.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) + .thenReturn(routeList); + Mockito.when(routeList.getResults()).thenReturn(List.of(firstRoute, secondRoute)); + Mockito.when(nsxService.apply(NatRules.class)).thenReturn(natRules); + Mockito.when(natRules.list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) + .thenReturn(ruleList); + Mockito.when(ruleList.getResults()).thenReturn(List.of(firstRule, secondRule)); + doThrow(new NotFound(null, null)).when(staticRoutes) + .delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route0"); + doThrow(new NotFound(null, null)).when(natRules) + .delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat0"); + + client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false); + + verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route0"); + verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route1"); + verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat0"); + verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat1"); + } + + @Test + public void testAddVpnConnectionRoutesPatchesDesiredBeforeDeletingStale() { + StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); + NatRules natRules = Mockito.mock(NatRules.class); + StaticRoutesListResult routeList = Mockito.mock(StaticRoutesListResult.class); + PolicyNatRuleListResult ruleList = Mockito.mock(PolicyNatRuleListResult.class); + com.vmware.nsx_policy.model.StaticRoutes desiredRoute = Mockito.mock(com.vmware.nsx_policy.model.StaticRoutes.class); + com.vmware.nsx_policy.model.StaticRoutes staleRoute = Mockito.mock(com.vmware.nsx_policy.model.StaticRoutes.class); + PolicyNatRule desiredRule = Mockito.mock(PolicyNatRule.class); + PolicyNatRule staleRule = Mockito.mock(PolicyNatRule.class); + Mockito.when(desiredRoute.getId()).thenReturn("cs-conn-connection-uuid-route0"); + Mockito.when(staleRoute.getId()).thenReturn("cs-conn-connection-uuid-route1"); + Mockito.when(desiredRule.getId()).thenReturn("cs-conn-connection-uuid-nosnat0"); + Mockito.when(staleRule.getId()).thenReturn("cs-conn-connection-uuid-nosnat1"); + Mockito.when(nsxService.apply(StaticRoutes.class)).thenReturn(staticRoutes); + Mockito.when(nsxService.apply(NatRules.class)).thenReturn(natRules); + Mockito.when(staticRoutes.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) + .thenReturn(routeList); + Mockito.when(routeList.getResults()).thenReturn(List.of(desiredRoute, staleRoute)); + Mockito.when(natRules.list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) + .thenReturn(ruleList); + Mockito.when(ruleList.getResults()).thenReturn(List.of(desiredRule, staleRule)); + + client.addVpnConnectionRoutes(TIER_1_GATEWAY_NAME, "connection-uuid", + List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16"); + + InOrder inOrder = Mockito.inOrder(staticRoutes, natRules); + inOrder.verify(staticRoutes).patch(eq(TIER_1_GATEWAY_NAME), eq("cs-conn-connection-uuid-route0"), + any(com.vmware.nsx_policy.model.StaticRoutes.class)); + inOrder.verify(natRules).patch(eq(TIER_1_GATEWAY_NAME), anyString(), + eq("cs-conn-connection-uuid-nosnat0"), any(PolicyNatRule.class)); + inOrder.verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route1"); + inOrder.verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat1"); + verify(staticRoutes, never()).delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route0"); + verify(natRules, never()).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat0"); + } + + @Test + public void testAddVpnConnectionRoutesPatchFailureDoesNotDeleteExistingResources() { + StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); + NatRules natRules = Mockito.mock(NatRules.class); + Mockito.when(nsxService.apply(StaticRoutes.class)).thenReturn(staticRoutes); + Mockito.when(nsxService.apply(NatRules.class)).thenReturn(natRules); + doThrow(new CloudRuntimeException("route patch failed")).when(staticRoutes) + .patch(anyString(), anyString(), any(com.vmware.nsx_policy.model.StaticRoutes.class)); + + assertThrows(CloudRuntimeException.class, () -> client.addVpnConnectionRoutes(TIER_1_GATEWAY_NAME, + "connection-uuid", List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16")); + + verify(staticRoutes, never()).list(anyString(), any(), anyBoolean(), any(), any(), any(), any()); + verify(natRules, never()).list(anyString(), anyString(), any(), anyBoolean(), any(), any(), any(), any()); + verify(staticRoutes, never()).delete(anyString(), anyString()); + verify(natRules, never()).delete(anyString(), anyString(), anyString()); + } + + @Test + public void testAddVpnConnectionRoutesRetriesMarkedForDeletion() { + StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); + NatRules natRules = Mockito.mock(NatRules.class); + Mockito.when(nsxService.apply(StaticRoutes.class)).thenReturn(staticRoutes); + Mockito.when(nsxService.apply(NatRules.class)).thenReturn(natRules); + mockEmptyVpnConnectionRouteLists(staticRoutes, natRules); + doThrow(new CloudRuntimeException("An object is marked for deletion")) + .doNothing() + .when(staticRoutes).patch(anyString(), anyString(), any(com.vmware.nsx_policy.model.StaticRoutes.class)); + + client.addVpnConnectionRoutes(TIER_1_GATEWAY_NAME, "connection-uuid", + List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16"); + + verify(staticRoutes, times(2)).patch(eq(TIER_1_GATEWAY_NAME), + eq("cs-conn-connection-uuid-route0"), any(com.vmware.nsx_policy.model.StaticRoutes.class)); + verify(natRules).patch(eq(TIER_1_GATEWAY_NAME), anyString(), + eq("cs-conn-connection-uuid-nosnat0"), any(PolicyNatRule.class)); + } + @Test public void testDeleteVpnConnectionContinuesCleanupAfterRouteAndNatFailures() { IpsecVpnIkeProfiles ikeProfiles = Mockito.mock(IpsecVpnIkeProfiles.class); @@ -656,6 +990,49 @@ private LbMonitorProfiles mockLbMonitorProfiles() { return lbMonitorProfiles; } + private RouteBasedIPSecVpnSession createCompleteVpnSession(String psk) { + IPSecVpnTunnelInterface tunnelInterface = new IPSecVpnTunnelInterface.Builder() + .setId("default-tunnel-interface") + .setDisplayName("default-tunnel-interface") + .setIpSubnets(List.of(new TunnelInterfaceIPSubnet.Builder() + .setIpAddresses(List.of("169.254.64.21")) + .setPrefixLength(30L) + .build())) + .build(); + RouteBasedIPSecVpnSession session = new RouteBasedIPSecVpnSession.Builder() + .setId("cs-conn-connection-uuid") + .setDisplayName("cs-conn-connection-uuid") + .setEnabled(true) + .setAuthenticationMode(IPSecVpnSession.AUTHENTICATION_MODE_PSK) + .setPsk(psk) + .setPeerAddress("203.0.113.10") + .setPeerId("203.0.113.10") + .setConnectionInitiationMode(IPSecVpnSession.CONNECTION_INITIATION_MODE_INITIATOR) + .setIkeProfilePath("/infra/ipsec-vpn-ike-profiles/ike") + .setTunnelProfilePath("/infra/ipsec-vpn-tunnel-profiles/esp") + .setDpdProfilePath("/infra/ipsec-vpn-dpd-profiles/dpd") + .setLocalEndpointPath("/infra/tier-1s/t1/ipsec-vpn-services/t1-vpn/local-endpoints/t1-vpn-le") + .setTunnelInterfaces(List.of(tunnelInterface)) + .build(); + session.setRevision(7L); + return session; + } + + private void mockEmptyVpnConnectionRouteLists(StaticRoutes staticRoutes, NatRules natRules) { + StaticRoutesListResult routeList = Mockito.mock(StaticRoutesListResult.class); + PolicyNatRuleListResult ruleList = Mockito.mock(PolicyNatRuleListResult.class); + Mockito.when(nsxService.apply(StaticRoutes.class)).thenReturn(staticRoutes); + Mockito.when(nsxService.apply(NatRules.class)).thenReturn(natRules); + Mockito.when(staticRoutes.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) + .thenReturn(routeList); + Mockito.when(routeList.getResults()).thenReturn(List.of()); + Mockito.when(natRules.list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) + .thenReturn(ruleList); + Mockito.when(ruleList.getResults()).thenReturn(List.of()); + } + private void mockLbAppProfiles() { LbAppProfiles lbAppProfiles = Mockito.mock(LbAppProfiles.class); LBAppProfileListResult appProfileListResult = Mockito.mock(LBAppProfileListResult.class); diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java index 0f74b215ac48..b8a5aa3c5b51 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java @@ -95,6 +95,7 @@ import java.lang.reflect.InvocationTargetException; import java.util.List; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -768,13 +769,25 @@ public void testReleaseVpnGatewayIpKeepsOperatorSpecifiedIp() { } @Test - public void testNsxVpnGatewayOwnershipIsRecordedForOperatorSpecifiedIp() { + public void testNsxVpnGatewayOwnershipDoesNotDependOnMarkerValue() { Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); when(vpnGateway.getAddrId()).thenReturn(30L); + UserIpAddressDetailVO operatorSpecified = new UserIpAddressDetailVO(30L, "nsxVpnGatewayIp", "false", false); + UserIpAddressDetailVO autoAcquired = new UserIpAddressDetailVO(30L, "nsxVpnGatewayIp", "true", false); when(userIpAddressDetailsDao.findDetail(30L, "nsxVpnGatewayIp")) - .thenReturn(Mockito.mock(UserIpAddressDetailVO.class)); + .thenReturn(operatorSpecified, autoAcquired); assertTrue(nsxElement.ownsVpnGateway(vpnGateway)); + assertTrue(nsxElement.ownsVpnGateway(vpnGateway)); + } + + @Test + public void testNsxVpnGatewayOwnershipRequiresMarker() { + Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); + when(vpnGateway.getAddrId()).thenReturn(30L); + when(userIpAddressDetailsDao.findDetail(30L, "nsxVpnGatewayIp")).thenReturn(null); + + assertFalse(nsxElement.ownsVpnGateway(vpnGateway)); } @Test @@ -871,12 +884,21 @@ private Site2SiteCustomerGatewayVO mockCustomerGateway(String ikePolicy, String } private Site2SiteVpnConnection mockVpnConnection(VpcVO vpcVO) { + return mockVpnConnection(vpcVO, true); + } + + private Site2SiteVpnConnection mockVpnConnection(VpcVO vpcVO, boolean nsxOwned) { Site2SiteVpnConnection connection = Mockito.mock(Site2SiteVpnConnection.class); when(connection.getVpnGatewayId()).thenReturn(7L); Mockito.lenient().when(connection.getCustomerGatewayId()).thenReturn(3L); Site2SiteVpnGatewayVO vpnGateway = Mockito.mock(Site2SiteVpnGatewayVO.class); when(vpnGatewayDao.findById(7L)).thenReturn(vpnGateway); when(vpnGateway.getVpcId()).thenReturn(9L); + when(vpnGateway.getAddrId()).thenReturn(30L); + if (nsxOwned) { + when(userIpAddressDetailsDao.findDetail(30L, "nsxVpnGatewayIp")) + .thenReturn(Mockito.mock(UserIpAddressDetailVO.class)); + } when(vpcDao.findById(9L)).thenReturn(vpcVO); return connection; } @@ -922,8 +944,7 @@ public void testStartSite2SiteVpnRejectsDnsPeerAddress() throws ResourceUnavaila @Test public void testStartSite2SiteVpnIsNoOpWhenVpnIsNotProvidedByNsx() throws ResourceUnavailableException { VpcVO vpcVO = Mockito.mock(VpcVO.class); - when(vpcVO.getVpcOfferingId()).thenReturn(11L); - Site2SiteVpnConnection connection = mockVpnConnection(vpcVO); + Site2SiteVpnConnection connection = mockVpnConnection(vpcVO, false); assertTrue(nsxElement.startSite2SiteVpn(connection)); verify(nsxService, Mockito.never()).createVpnConnection(any(Vpc.class), any(), anyString(), anyString(), diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java index a116d36756fa..a507c78f3ec8 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java @@ -20,6 +20,9 @@ import com.cloud.network.dao.NetworkVO; import com.cloud.network.Site2SiteVpnConnection; import com.cloud.network.dao.Site2SiteVpnConnectionVO; +import com.cloud.network.dao.Site2SiteVpnConnectionDao; +import com.cloud.network.dao.Site2SiteVpnGatewayDao; +import com.cloud.network.dao.Site2SiteVpnGatewayVO; import com.cloud.network.vpc.Vpc; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; @@ -35,6 +38,8 @@ import org.apache.cloudstack.agent.api.DeleteNsxSegmentCommand; import org.apache.cloudstack.agent.api.DeleteNsxTier1GatewayCommand; import org.apache.cloudstack.utils.NsxControllerUtils; +import org.apache.cloudstack.resourcedetail.UserIpAddressDetailVO; +import org.apache.cloudstack.resourcedetail.dao.UserIpAddressDetailsDao; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -44,6 +49,7 @@ import org.mockito.MockitoAnnotations; import org.mockito.junit.MockitoJUnitRunner; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -54,6 +60,9 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -62,6 +71,12 @@ public class NsxServiceImplTest { private NsxControllerUtils nsxControllerUtils; @Mock private VpcDao vpcDao; + @Mock + private Site2SiteVpnConnectionDao site2SiteVpnConnectionDao; + @Mock + private Site2SiteVpnGatewayDao site2SiteVpnGatewayDao; + @Mock + private UserIpAddressDetailsDao userIpAddressDetailsDao; NsxServiceImpl nsxService; AutoCloseable closeable; @@ -76,6 +91,9 @@ public void setup() { nsxService = new NsxServiceImpl(); nsxService.nsxControllerUtils = nsxControllerUtils; nsxService.vpcDao = vpcDao; + nsxService.site2SiteVpnConnectionDao = site2SiteVpnConnectionDao; + nsxService.site2SiteVpnGatewayDao = site2SiteVpnGatewayDao; + nsxService.userIpAddressDetailsDao = userIpAddressDetailsDao; } @After @@ -204,6 +222,7 @@ public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { @Override protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, + Site2SiteVpnConnection.State observedState, Site2SiteVpnConnection.State newState) { transitionedState.set(newState); } @@ -229,6 +248,7 @@ public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { @Override protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, + Site2SiteVpnConnection.State observedState, Site2SiteVpnConnection.State newState) { transitioned.set(true); } @@ -239,4 +259,69 @@ protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, // A transient management-plane error must not turn a valid connection into Error. assertTrue(!transitioned.get()); } + + @Test + public void testTransitionVpnConnectionStateIgnoresStaleStatusObservation() { + Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); + Site2SiteVpnConnectionVO lock = mock(Site2SiteVpnConnectionVO.class); + Site2SiteVpnConnectionVO current = mock(Site2SiteVpnConnectionVO.class); + VpcVO vpc = mock(VpcVO.class); + when(connection.getId()).thenReturn(11L); + when(lock.getId()).thenReturn(11L); + when(current.getState()).thenReturn(Site2SiteVpnConnection.State.Disconnected); + when(site2SiteVpnConnectionDao.acquireInLockTable(11L)).thenReturn(lock); + when(site2SiteVpnConnectionDao.findById(11L)).thenReturn(current); + + nsxService.transitionVpnConnectionState(connection, vpc, Site2SiteVpnConnection.State.Connecting, + Site2SiteVpnConnection.State.Connected); + + verify(site2SiteVpnConnectionDao, never()).persist(current); + verify(site2SiteVpnConnectionDao).releaseFromLockTable(11L); + } + + @Test + public void testVpnStatusPollerSkipsUnmarkedGatewayRegardlessOfCurrentOffering() { + Site2SiteVpnConnectionVO connection = mockPollableVpnConnection(); + Site2SiteVpnGatewayVO gateway = mockVpnGatewayForPoller(connection); + VpcVO vpc = mock(VpcVO.class); + when(vpcDao.findById(gateway.getVpcId())).thenReturn(vpc); + NsxServiceImpl service = Mockito.spy(nsxService); + + service.new VpnStatusPollTask().runInContext(); + + verify(service, never()).pollVpnConnectionStatus(connection, vpc); + } + + @Test + public void testVpnStatusPollerUsesPersistedOwnershipAfterOfferingChanges() { + Site2SiteVpnConnectionVO connection = mockPollableVpnConnection(); + Site2SiteVpnGatewayVO gateway = mockVpnGatewayForPoller(connection); + VpcVO vpc = mock(VpcVO.class); + when(vpcDao.findById(gateway.getVpcId())).thenReturn(vpc); + when(userIpAddressDetailsDao.findDetail(gateway.getAddrId(), NsxElement.NSX_VPN_GATEWAY_IP_DETAIL)) + .thenReturn(mock(UserIpAddressDetailVO.class)); + NsxServiceImpl service = Mockito.spy(nsxService); + doNothing().when(service).pollVpnConnectionStatus(connection, vpc); + + service.new VpnStatusPollTask().runInContext(); + + verify(service).pollVpnConnectionStatus(connection, vpc); + } + + private Site2SiteVpnConnectionVO mockPollableVpnConnection() { + Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); + when(connection.getId()).thenReturn(11L); + when(connection.getVpnGatewayId()).thenReturn(7L); + when(connection.getState()).thenReturn(Site2SiteVpnConnection.State.Connected); + when(site2SiteVpnConnectionDao.listAll()).thenReturn(List.of(connection)); + return connection; + } + + private Site2SiteVpnGatewayVO mockVpnGatewayForPoller(Site2SiteVpnConnectionVO connection) { + Site2SiteVpnGatewayVO gateway = mock(Site2SiteVpnGatewayVO.class); + when(gateway.getVpcId()).thenReturn(9L); + when(gateway.getAddrId()).thenReturn(30L); + when(site2SiteVpnGatewayDao.findById(connection.getVpnGatewayId())).thenReturn(gateway); + return gateway; + } } diff --git a/server/src/main/java/com/cloud/network/vpn/RemoteAccessVpnManagerImpl.java b/server/src/main/java/com/cloud/network/vpn/RemoteAccessVpnManagerImpl.java index 24c2f2221d96..d7d650f27781 100644 --- a/server/src/main/java/com/cloud/network/vpn/RemoteAccessVpnManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpn/RemoteAccessVpnManagerImpl.java @@ -60,6 +60,7 @@ import com.cloud.network.dao.RemoteAccessVpnDao; import com.cloud.network.dao.RemoteAccessVpnVO; import com.cloud.network.dao.VpnUserDao; +import com.cloud.network.element.NetworkElement; import com.cloud.network.element.RemoteAccessVPNServiceProvider; import com.cloud.network.rules.FirewallManager; import com.cloud.network.rules.FirewallRule; @@ -282,8 +283,41 @@ public RemoteAccessVpn createRemoteAccessVpn(final long publicIpId, String ipRan } } - private void validateIpAddressForVpnServiceOnNetwork(Network network, IPAddressVO ipAddress) { + private RemoteAccessVPNServiceProvider getRemoteAccessVpnServiceProvider(Long networkId, Long vpcId) { + List providers = new ArrayList<>(); + for (RemoteAccessVPNServiceProvider provider : _vpnServiceProviders) { + if (!(provider instanceof NetworkElement)) { + continue; + } + Network.Provider networkProvider = ((NetworkElement) provider).getProvider(); + boolean supportsRemoteAccessVpn = vpcId != null + ? vpcManager.isProviderSupportServiceInVpc(vpcId, Service.Vpn, networkProvider) + : networkId != null && _networkMgr.isProviderSupportServiceInNetwork(networkId, Service.Vpn, networkProvider); + if (supportsRemoteAccessVpn) { + providers.add(provider); + } + } + if (providers.size() > 1) { + throw new InvalidParameterValueException(String.format( + "More than one Remote Access VPN provider is configured for %s %s", + vpcId != null ? "VPC" : "network", vpcId != null ? vpcId : networkId)); + } + return providers.isEmpty() ? null : providers.get(0); + } + + private RemoteAccessVPNServiceProvider requireRemoteAccessVpnServiceProvider(Long networkId, Long vpcId) { + RemoteAccessVPNServiceProvider provider = getRemoteAccessVpnServiceProvider(networkId, vpcId); + if (provider == null) { + throw new InvalidParameterValueException(String.format( + "Remote Access VPN is not supported for %s %s because its configured Vpn provider does not implement the Remote Access VPN service", + vpcId != null ? "VPC" : "network", vpcId != null ? vpcId : networkId)); + } + return provider; + } + + void validateIpAddressForVpnServiceOnNetwork(Network network, IPAddressVO ipAddress) { Long networkId = network.getId(); + requireRemoteAccessVpnServiceProvider(networkId, null); if (_networkMgr.isProviderSupportServiceInNetwork(networkId, Service.Vpn, Network.Provider.VirtualRouter)) { // if VR is the VPN provider, // (1) if VR is Source NAT, the IP address must be used as Source NAT @@ -301,8 +335,9 @@ private void validateIpAddressForVpnServiceOnNetwork(Network network, IPAddressV } } - private void validateIpAddressForVpnServiceOnVpc(Vpc vpc, IPAddressVO ipAddress) { + void validateIpAddressForVpnServiceOnVpc(Vpc vpc, IPAddressVO ipAddress) { Long vpcId = vpc.getId(); + requireRemoteAccessVpnServiceProvider(null, vpcId); if (vpcManager.isProviderSupportServiceInVpc(vpcId, Service.Vpn, Network.Provider.VPCVirtualRouter)) { // if VPC VR is the VPN provider, // (1) if VPC VR is Source NAT, the IP address must be used as Source NAT @@ -545,6 +580,7 @@ public RemoteAccessVpnVO startRemoteAccessVpn(long ipAddressId, boolean openFire _accountMgr.checkAccess(caller, null, true, vpn); + RemoteAccessVPNServiceProvider provider = requireRemoteAccessVpnServiceProvider(vpn.getNetworkId(), vpn.getVpcId()); boolean started = false; try { boolean firewallOpened = true; @@ -552,13 +588,13 @@ public RemoteAccessVpnVO startRemoteAccessVpn(long ipAddressId, boolean openFire firewallOpened = _firewallMgr.applyIngressFirewallRules(vpn.getServerAddressId(), caller); } - if (firewallOpened) { - for (RemoteAccessVPNServiceProvider element : _vpnServiceProviders) { - if (element.startVpn(vpn)) { - started = true; - break; - } - } + if (firewallOpened && provider.startVpn(vpn)) { + started = true; + } + if (!started) { + throw new ResourceUnavailableException(String.format( + "Failed to start Remote Access VPN %s using provider %s", vpn.getId(), provider.getName()), + RemoteAccessVpn.class, vpn.getId()); } return vpn; diff --git a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java index 60aa96b955cb..ed1862371641 100644 --- a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java @@ -683,58 +683,57 @@ public Site2SiteVpnConnection startVpnConnection(long id) throws ResourceUnavail throw new CloudRuntimeException("Unable to acquire lock for starting of VPN connection with ID " + id); } try { - if (conn.getState() != State.Pending && conn.getState() != State.Disconnected) { - throw new InvalidParameterValueException( + return startVpnConnectionLocked(conn); + } finally { + _vpnConnectionDao.releaseFromLockTable(conn.getId()); + } + } + + private Site2SiteVpnConnectionVO startVpnConnectionLocked(Site2SiteVpnConnectionVO conn) throws ResourceUnavailableException { + if (conn.getState() != State.Pending && conn.getState() != State.Disconnected) { + throw new InvalidParameterValueException( "Site to site VPN connection with specified connectionId not in correct state(pending or disconnected) to process!"); - } + } - conn.setState(State.Pending); - _vpnConnectionDao.persist(conn); + conn.setState(State.Pending); + _vpnConnectionDao.persist(conn); - final Site2SiteVpnGateway vpnGateway = _vpnGatewayDao.findById(conn.getVpnGatewayId()); - if (vpnGateway == null) { - throw new CloudRuntimeException(String.format("Unable to find VPN gateway %s for connection %s", - conn.getVpnGatewayId(), conn.getUuid())); - } + try { + Site2SiteVpnGateway vpnGateway = getVpnGatewayForConnection(conn); try { vpcManager.applyStaticRouteForVpcVpnIfNeeded(vpnGateway.getVpcId(), false); } catch (ResourceUnavailableException | CloudRuntimeException e) { - logger.error("Unable to apply static routes for vpc " + vpnGateway.getVpcId() + "as part of start of VPN connection, due to " + e.getMessage()); + logger.error("Unable to apply static routes for vpc {} as part of start of VPN connection, due to {}", + vpnGateway.getVpcId(), e.getMessage()); } - Site2SiteVpnServiceProvider provider = getVpnServiceProviderForGateway(vpnGateway); if (provider == null) { throw new InvalidParameterValueException(String.format( "No Site-to-Site VPN provider owns gateway %s", vpnGateway.getId())); } - boolean result = provider.startSite2SiteVpn(conn); - - if (result) { - if (conn.isPassive()) { - conn.setState(State.Disconnected); - } else { - conn.setState(State.Connecting); - } - _vpnConnectionDao.persist(conn); - return conn; + if (!provider.startSite2SiteVpn(conn)) { + throw new ResourceUnavailableException("Failed to apply site-to-site VPN", + Site2SiteVpnConnection.class, conn.getId()); } - conn.setState(State.Error); + conn.setState(conn.isPassive() ? State.Disconnected : State.Connecting); _vpnConnectionDao.persist(conn); - throw new ResourceUnavailableException("Failed to apply site-to-site VPN", Site2SiteVpnConnection.class, id); + return conn; } catch (ResourceUnavailableException | RuntimeException e) { - // Provider validation and provisioning failures must not leave a connection Pending. - // Pending is reserved for work that has been accepted and can be retried by the - // normal lifecycle; a synchronous failure is an actionable Error state. - if (conn.getState() == State.Pending) { - conn.setState(State.Error); - _vpnConnectionDao.persist(conn); - } + conn.setState(State.Error); + _vpnConnectionDao.persist(conn); throw e; - } finally { - _vpnConnectionDao.releaseFromLockTable(conn.getId()); } } + private Site2SiteVpnGateway getVpnGatewayForConnection(Site2SiteVpnConnection conn) { + Site2SiteVpnGateway vpnGateway = _vpnGatewayDao.findById(conn.getVpnGatewayId()); + if (vpnGateway == null) { + throw new CloudRuntimeException(String.format("Unable to find VPN gateway %s for connection %s", + conn.getVpnGatewayId(), conn.getUuid())); + } + return vpnGateway; + } + @Override public Site2SiteVpnGateway getVpnGateway(Long vpnGatewayId) { return _vpnGatewayDao.findById(vpnGatewayId); @@ -795,6 +794,7 @@ public boolean deleteVpnGateway(DeleteVpnGatewayCmd cmd) { } @Override + @DB @ActionEvent(eventType = EventTypes.EVENT_S2S_VPN_CUSTOMER_GATEWAY_UPDATE, eventDescription = "update s2s vpn customer gateway", create = true) public Site2SiteCustomerGateway updateCustomerGateway(UpdateVpnCustomerGatewayCmd cmd) { Account caller = CallContext.current().getCallingAccount(); @@ -912,127 +912,125 @@ public Site2SiteCustomerGateway updateCustomerGateway(UpdateVpnCustomerGatewayCm private void setupVpnConnection(Account caller, Long vpnCustomerGwIp) { List conns = _vpnConnectionDao.listByCustomerGatewayId(vpnCustomerGwIp); if (conns != null) { - for (Site2SiteVpnConnection conn : conns) { - try { - _accountMgr.checkAccess(caller, null, false, conn); - } catch (PermissionDeniedException e) { - // Just don't restart this connection, as the user has no rights to it - // Maybe should issue a notification to the system? - logger.info("Site2SiteVpnManager:updateCustomerGateway() Not resetting VPN connection {} as user lacks permission", conn); - continue; - } - - if (conn.getState() == State.Pending) { - // Vpn connection cannot be reset when the state is Pending - continue; + for (Site2SiteVpnConnectionVO listedConnection : conns) { + Site2SiteVpnConnectionVO conn = _vpnConnectionDao.acquireInLockTable(listedConnection.getId()); + if (conn == null) { + throw new CloudRuntimeException("Unable to acquire lock for restarting VPN connection with ID " + listedConnection.getId()); } try { + try { + _accountMgr.checkAccess(caller, null, false, conn); + } catch (PermissionDeniedException e) { + logger.info("Site2SiteVpnManager:updateCustomerGateway() Not resetting VPN connection {} as user lacks permission", conn); + continue; + } + if (conn.getState() == State.Pending || conn.getState() == State.Removed) { + continue; + } if (conn.getState() == State.Connected || conn.getState() == State.Connecting || conn.getState() == State.Error) { - stopVpnConnection(conn.getId()); + stopVpnConnectionLocked(conn, false); } - startVpnConnection(conn.getId()); + startVpnConnectionLocked(conn); } catch (ResourceUnavailableException e) { - // Should never get here, as we are looping on the actual connections, but we must handle it regardless - logger.warn("Failed to update VPN connection"); + logger.warn("Failed to restart VPN connection {} after updating its customer gateway: {}", + conn.getUuid(), e.getMessage()); + } finally { + _vpnConnectionDao.releaseFromLockTable(conn.getId()); } } } } @Override + @DB @ActionEvent(eventType = EventTypes.EVENT_S2S_VPN_CONNECTION_DELETE, eventDescription = "deleting s2s vpn connection", create = true) public boolean deleteVpnConnection(DeleteVpnConnectionCmd cmd) throws ResourceUnavailableException { Account caller = CallContext.current().getCallingAccount(); Long id = cmd.getId(); - Site2SiteVpnConnectionVO conn = _vpnConnectionDao.findById(id); + Site2SiteVpnConnectionVO conn = _vpnConnectionDao.acquireInLockTable(id); if (conn == null) { - throw new InvalidParameterValueException("Fail to find site to site VPN connection " + id + " to delete!"); + if (_vpnConnectionDao.findById(id) == null) { + throw new InvalidParameterValueException("Fail to find site to site VPN connection " + id + " to delete!"); + } + throw new CloudRuntimeException("Unable to acquire lock for deleting VPN connection with ID " + id); } - CallContext.current().setEventDetails(" ID: " + conn.getUuid()); - - _accountMgr.checkAccess(caller, null, false, conn); - - stopVpnConnection(id, true); + try { + CallContext.current().setEventDetails(" ID: " + conn.getUuid()); + _accountMgr.checkAccess(caller, null, false, conn); - conn.setState(State.Removed); - _vpnConnectionDao.update(id, conn); + stopVpnConnectionLocked(conn, true); - final Site2SiteVpnGateway vpnGateway = _vpnGatewayDao.findById(conn.getVpnGatewayId()); - try { - vpcManager.applyStaticRouteForVpcVpnIfNeeded(vpnGateway.getVpcId(), false); - } catch (ResourceUnavailableException | CloudRuntimeException e) { - logger.error("Unable to apply static routes for vpc " + vpnGateway.getVpcId() + "as part of deletion of VPN connection, due to " + e.getMessage()); - } + conn.setState(State.Removed); + _vpnConnectionDao.update(id, conn); - _vpnConnectionDao.remove(id); + Site2SiteVpnGateway vpnGateway = getVpnGatewayForConnection(conn); + try { + vpcManager.applyStaticRouteForVpcVpnIfNeeded(vpnGateway.getVpcId(), false); + } catch (ResourceUnavailableException | CloudRuntimeException e) { + logger.error("Unable to apply static routes for vpc {} as part of deletion of VPN connection, due to {}", + vpnGateway.getVpcId(), e.getMessage()); + } - return true; - } + _vpnConnectionDao.remove(id); - @DB - private void stopVpnConnection(Long id) throws ResourceUnavailableException { - stopVpnConnection(id, false); + return true; + } finally { + _vpnConnectionDao.releaseFromLockTable(conn.getId()); + } } - @DB - private void stopVpnConnection(Long id, boolean deleting) throws ResourceUnavailableException { - Site2SiteVpnConnectionVO conn = _vpnConnectionDao.acquireInLockTable(id); - if (conn == null) { - throw new CloudRuntimeException("Unable to acquire lock for stopping VPN connection with ID " + id); + private void stopVpnConnectionLocked(Site2SiteVpnConnectionVO conn, boolean deleting) throws ResourceUnavailableException { + if (conn.getState() == State.Pending && !deleting) { + throw new InvalidParameterValueException("Site to site VPN connection with specified id is currently Pending, unable to Disconnect!"); } - try { - if (conn.getState() == State.Pending && !deleting) { - throw new InvalidParameterValueException("Site to site VPN connection with specified id is currently Pending, unable to Disconnect!"); - } - conn.setState(State.Disconnected); - _vpnConnectionDao.persist(conn); + conn.setState(State.Disconnected); + _vpnConnectionDao.persist(conn); - Site2SiteVpnGateway vpnGateway = _vpnGatewayDao.findById(conn.getVpnGatewayId()); - if (vpnGateway == null) { - throw new CloudRuntimeException(String.format("Unable to find VPN gateway %s for connection %s", - conn.getVpnGatewayId(), conn.getUuid())); - } + Site2SiteVpnGateway vpnGateway = getVpnGatewayForConnection(conn); + try { Site2SiteVpnServiceProvider provider = getVpnServiceProviderForGateway(vpnGateway); if (provider == null) { throw new CloudRuntimeException(String.format( "No Site-to-Site VPN provider owns gateway %s", vpnGateway.getId())); } boolean result = deleting ? provider.deleteSite2SiteVpn(conn) : provider.stopSite2SiteVpn(conn); - if (!result) { - conn.setState(State.Error); - _vpnConnectionDao.persist(conn); - throw new ResourceUnavailableException("Failed to apply site-to-site VPN", Site2SiteVpnConnection.class, id); + throw new ResourceUnavailableException("Failed to apply site-to-site VPN", + Site2SiteVpnConnection.class, conn.getId()); } - } finally { - _vpnConnectionDao.releaseFromLockTable(conn.getId()); + } catch (ResourceUnavailableException | RuntimeException e) { + conn.setState(State.Error); + _vpnConnectionDao.persist(conn); + throw e; } } @Override + @DB @ActionEvent(eventType = EventTypes.EVENT_S2S_VPN_CONNECTION_RESET, eventDescription = "reseting s2s vpn connection", create = true) public Site2SiteVpnConnection resetVpnConnection(ResetVpnConnectionCmd cmd) throws ResourceUnavailableException { Account caller = CallContext.current().getCallingAccount(); Long id = cmd.getId(); - Site2SiteVpnConnectionVO conn = _vpnConnectionDao.findById(id); + Site2SiteVpnConnectionVO conn = _vpnConnectionDao.acquireInLockTable(id); if (conn == null) { - throw new InvalidParameterValueException("Fail to find site to site VPN connection " + id + " to reset!"); + if (_vpnConnectionDao.findById(id) == null) { + throw new InvalidParameterValueException("Fail to find site to site VPN connection " + id + " to reset!"); + } + throw new CloudRuntimeException("Unable to acquire lock for resetting VPN connection with ID " + id); } - CallContext.current().setEventDetails(" ID: " + conn.getUuid()); - _accountMgr.checkAccess(caller, null, false, conn); - - // Set vpn state to disconnected - conn.setState(State.Disconnected); - _vpnConnectionDao.persist(conn); + try { + CallContext.current().setEventDetails(" ID: " + conn.getUuid()); + _accountMgr.checkAccess(caller, null, false, conn); - // Stop and start the connection again - stopVpnConnection(id); - startVpnConnection(id); - conn = _vpnConnectionDao.findById(id); - return conn; + conn.setState(State.Disconnected); + stopVpnConnectionLocked(conn, false); + return startVpnConnectionLocked(conn); + } finally { + _vpnConnectionDao.releaseFromLockTable(conn.getId()); + } } @Override diff --git a/server/src/test/java/com/cloud/network/vpn/RemoteAccessVpnManagerImplTest.java b/server/src/test/java/com/cloud/network/vpn/RemoteAccessVpnManagerImplTest.java index f8b4362e76b5..1a39a29e9f53 100644 --- a/server/src/test/java/com/cloud/network/vpn/RemoteAccessVpnManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpn/RemoteAccessVpnManagerImplTest.java @@ -15,8 +15,21 @@ package com.cloud.network.vpn; import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.Network; +import com.cloud.network.dao.IPAddressVO; +import com.cloud.network.dao.RemoteAccessVpnDao; +import com.cloud.network.dao.RemoteAccessVpnVO; +import com.cloud.network.element.NetworkElement; +import com.cloud.network.element.RemoteAccessVPNServiceProvider; +import com.cloud.network.rules.FirewallManager; +import com.cloud.network.vpc.Vpc; +import com.cloud.network.vpc.VpcManager; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.net.NetUtils; +import org.apache.cloudstack.context.CallContext; import junit.framework.TestCase; import org.junit.Assert; import org.junit.Test; @@ -27,13 +40,137 @@ import javax.naming.ConfigurationException; import java.lang.reflect.InvocationTargetException; +import java.util.List; @RunWith(MockitoJUnitRunner.class) public class RemoteAccessVpnManagerImplTest extends TestCase { + private static final long NETWORK_ID = 11L; + private static final long VPC_ID = 12L; + Class expectedException = InvalidParameterValueException.class; Class cloudRuntimeException = CloudRuntimeException.class; + private RemoteAccessVPNServiceProvider mockProvider(Network.Provider networkProvider) { + RemoteAccessVPNServiceProvider provider = Mockito.mock(RemoteAccessVPNServiceProvider.class, + Mockito.withSettings().extraInterfaces(NetworkElement.class)); + Mockito.when(((NetworkElement) provider).getProvider()).thenReturn(networkProvider); + return provider; + } + + private RemoteAccessVpnManagerImpl managerWithProvider(RemoteAccessVPNServiceProvider provider) { + RemoteAccessVpnManagerImpl manager = new RemoteAccessVpnManagerImpl(); + manager._vpnServiceProviders = List.of(provider); + manager._networkMgr = Mockito.mock(com.cloud.network.NetworkModel.class); + manager.vpcManager = Mockito.mock(VpcManager.class); + return manager; + } + + @Test + public void validateVpcRemoteAccessVpnAcceptsMappedVpcVirtualRouterProvider() { + RemoteAccessVPNServiceProvider provider = mockProvider(Network.Provider.VPCVirtualRouter); + RemoteAccessVpnManagerImpl manager = managerWithProvider(provider); + Vpc vpc = Mockito.mock(Vpc.class); + IPAddressVO ipAddress = Mockito.mock(IPAddressVO.class); + Mockito.when(vpc.getId()).thenReturn(VPC_ID); + Mockito.when(manager.vpcManager.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, + Network.Provider.VPCVirtualRouter)).thenReturn(true); + Mockito.when(manager.vpcManager.isProviderSupportServiceInVpc(VPC_ID, Network.Service.SourceNat, + Network.Provider.VPCVirtualRouter)).thenReturn(true); + Mockito.when(ipAddress.isSourceNat()).thenReturn(true); + + manager.validateIpAddressForVpnServiceOnVpc(vpc, ipAddress); + } + + @Test + public void validateVpcRemoteAccessVpnRejectsProviderWithoutRemoteAccessImplementationBeforePersistence() { + RemoteAccessVPNServiceProvider provider = mockProvider(Network.Provider.VPCVirtualRouter); + RemoteAccessVpnManagerImpl manager = managerWithProvider(provider); + Vpc vpc = Mockito.mock(Vpc.class); + IPAddressVO ipAddress = Mockito.mock(IPAddressVO.class); + Mockito.when(vpc.getId()).thenReturn(VPC_ID); + Mockito.when(manager.vpcManager.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, + Network.Provider.VPCVirtualRouter)).thenReturn(false); + InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class, + () -> manager.validateIpAddressForVpnServiceOnVpc(vpc, ipAddress)); + + Assert.assertTrue(exception.getMessage().contains("does not implement the Remote Access VPN service")); + } + + @Test + public void validateNetworkRemoteAccessVpnAcceptsMappedVirtualRouterProvider() { + RemoteAccessVPNServiceProvider provider = mockProvider(Network.Provider.VirtualRouter); + RemoteAccessVpnManagerImpl manager = managerWithProvider(provider); + Network network = Mockito.mock(Network.class); + IPAddressVO ipAddress = Mockito.mock(IPAddressVO.class); + Mockito.when(network.getId()).thenReturn(NETWORK_ID); + Mockito.when(manager._networkMgr.isProviderSupportServiceInNetwork(NETWORK_ID, Network.Service.Vpn, + Network.Provider.VirtualRouter)).thenReturn(true); + Mockito.when(manager._networkMgr.isProviderSupportServiceInNetwork(NETWORK_ID, Network.Service.SourceNat, + Network.Provider.VirtualRouter)).thenReturn(true); + Mockito.when(ipAddress.isSourceNat()).thenReturn(true); + + manager.validateIpAddressForVpnServiceOnNetwork(network, ipAddress); + } + + @Test + public void startRemoteAccessVpnRejectsLegacyRecordWithoutMappedProvider() throws ResourceUnavailableException { + RemoteAccessVPNServiceProvider provider = mockProvider(Network.Provider.VPCVirtualRouter); + RemoteAccessVpnManagerImpl manager = managerWithProvider(provider); + manager._remoteAccessVpnDao = Mockito.mock(RemoteAccessVpnDao.class); + manager._accountMgr = Mockito.mock(AccountManager.class); + manager._firewallMgr = Mockito.mock(FirewallManager.class); + RemoteAccessVpnVO vpn = Mockito.mock(RemoteAccessVpnVO.class); + Account caller = Mockito.mock(Account.class); + CallContext context = Mockito.mock(CallContext.class); + Mockito.when(vpn.getVpcId()).thenReturn(VPC_ID); + Mockito.when(manager._remoteAccessVpnDao.findByPublicIpAddress(31L)).thenReturn(vpn); + Mockito.when(manager.vpcManager.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, + Network.Provider.VPCVirtualRouter)).thenReturn(false); + Mockito.when(context.getCallingAccount()).thenReturn(caller); + + try (MockedStatic callContext = Mockito.mockStatic(CallContext.class)) { + callContext.when(CallContext::current).thenReturn(context); + + InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class, + () -> manager.startRemoteAccessVpn(31L, false)); + + Assert.assertTrue(exception.getMessage().contains("does not implement the Remote Access VPN service")); + } + Mockito.verify(provider, Mockito.never()).startVpn(vpn); + Mockito.verify(manager._remoteAccessVpnDao, Mockito.never()).update(Mockito.anyLong(), Mockito.any()); + } + + @Test + public void startRemoteAccessVpnFailsWhenMappedProviderDoesNotStartIt() throws ResourceUnavailableException { + RemoteAccessVPNServiceProvider provider = mockProvider(Network.Provider.VPCVirtualRouter); + RemoteAccessVpnManagerImpl manager = managerWithProvider(provider); + manager._remoteAccessVpnDao = Mockito.mock(RemoteAccessVpnDao.class); + manager._accountMgr = Mockito.mock(AccountManager.class); + manager._firewallMgr = Mockito.mock(FirewallManager.class); + RemoteAccessVpnVO vpn = Mockito.mock(RemoteAccessVpnVO.class); + Account caller = Mockito.mock(Account.class); + CallContext context = Mockito.mock(CallContext.class); + Mockito.when(vpn.getId()).thenReturn(22L); + Mockito.when(vpn.getVpcId()).thenReturn(VPC_ID); + Mockito.when(manager._remoteAccessVpnDao.findByPublicIpAddress(32L)).thenReturn(vpn); + Mockito.when(manager.vpcManager.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, + Network.Provider.VPCVirtualRouter)).thenReturn(true); + Mockito.when(context.getCallingAccount()).thenReturn(caller); + Mockito.when(provider.startVpn(vpn)).thenReturn(false); + Mockito.when(provider.getName()).thenReturn("VpcVirtualRouter"); + + try (MockedStatic callContext = Mockito.mockStatic(CallContext.class)) { + callContext.when(CallContext::current).thenReturn(context); + + ResourceUnavailableException exception = Assert.assertThrows(ResourceUnavailableException.class, + () -> manager.startRemoteAccessVpn(32L, false)); + + Assert.assertTrue(exception.getMessage().contains("Failed to start Remote Access VPN")); + } + Mockito.verify(manager._remoteAccessVpnDao, Mockito.never()).update(Mockito.anyLong(), Mockito.any()); + } + @Test public void validateValidateIpRangeRangeLengthLessThan2MustThrowException(){ String ipRange = "192.168.0.1"; diff --git a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java index 14909b057665..15b5156ff805 100644 --- a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java @@ -19,6 +19,7 @@ package com.cloud.network.vpn; import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.PermissionDeniedException; import com.cloud.exception.ResourceUnavailableException; import com.cloud.network.Site2SiteVpnConnection; import com.cloud.network.Site2SiteVpnConnection.State; @@ -43,6 +44,7 @@ import com.cloud.user.AccountVO; import com.cloud.user.User; import com.cloud.user.UserVO; +import com.cloud.utils.db.DB; import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.net.Ip; import com.cloud.utils.net.NetUtils; @@ -64,10 +66,12 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; import java.lang.reflect.Field; import java.util.ArrayList; @@ -84,8 +88,10 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -166,6 +172,7 @@ public void setUp() throws Exception { when(customerGateway.getIkeVersion()).thenReturn("ike"); vpnConnection = new Site2SiteVpnConnectionVO(ACCOUNT_ID, DOMAIN_ID, VPN_GATEWAY_ID, CUSTOMER_GATEWAY_ID, false); + ReflectionTestUtils.setField(vpnConnection, "id", VPN_CONNECTION_ID); vpnConnection.setState(State.Pending); when(_accountMgr.getAccount(ACCOUNT_ID)).thenReturn(account); @@ -633,6 +640,56 @@ public void testUpdateCustomerGatewayValidatesProposedValuesBeforePersist() { verify(_customerGatewayDao, never()).persist(customerGateway); } + @Test + public void testCustomerGatewayUpdateRestartUsesSingleConnectionLock() throws ResourceUnavailableException { + vpnConnection.setState(State.Connected); + when(_vpnConnectionDao.listByCustomerGatewayId(CUSTOMER_GATEWAY_ID)).thenReturn(List.of(vpnConnection)); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + when(_vpcMgr.applyStaticRouteForVpcVpnIfNeeded(VPC_ID, false)).thenReturn(true); + Site2SiteVpnServiceProvider provider = mockVpcVirtualRouterProvider(); + when(provider.stopSite2SiteVpn(vpnConnection)).thenReturn(true); + when(provider.startSite2SiteVpn(vpnConnection)).thenReturn(true); + + ReflectionTestUtils.invokeMethod(site2SiteVpnManager, "setupVpnConnection", account, CUSTOMER_GATEWAY_ID); + + verify(_vpnConnectionDao, times(1)).acquireInLockTable(VPN_CONNECTION_ID); + InOrder lifecycle = inOrder(provider, _vpcMgr, _vpnConnectionDao); + lifecycle.verify(provider).stopSite2SiteVpn(vpnConnection); + lifecycle.verify(_vpcMgr).applyStaticRouteForVpcVpnIfNeeded(VPC_ID, false); + lifecycle.verify(provider).startSite2SiteVpn(vpnConnection); + lifecycle.verify(_vpnConnectionDao).releaseFromLockTable(VPN_CONNECTION_ID); + } + + @Test + public void testUpdateCustomerGatewayDefinesDatabaseContextForConnectionLock() throws NoSuchMethodException { + assertTrue(Site2SiteVpnManagerImpl.class.getMethod("updateCustomerGateway", UpdateVpnCustomerGatewayCmd.class) + .isAnnotationPresent(DB.class)); + } + + @Test + public void testCustomerGatewayUpdateLockFailureIsReported() { + when(_vpnConnectionDao.listByCustomerGatewayId(CUSTOMER_GATEWAY_ID)).thenReturn(List.of(vpnConnection)); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(null); + + assertThrows(CloudRuntimeException.class, + () -> ReflectionTestUtils.invokeMethod(site2SiteVpnManager, "setupVpnConnection", account, CUSTOMER_GATEWAY_ID)); + + verify(_vpnConnectionDao, never()).releaseFromLockTable(VPN_CONNECTION_ID); + } + + @Test + public void testCustomerGatewayUpdateDoesNotRestartPendingConnection() { + vpnConnection.setState(State.Pending); + when(_vpnConnectionDao.listByCustomerGatewayId(CUSTOMER_GATEWAY_ID)).thenReturn(List.of(vpnConnection)); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + + ReflectionTestUtils.invokeMethod(site2SiteVpnManager, "setupVpnConnection", account, CUSTOMER_GATEWAY_ID); + + verify(_vpnGatewayDao, never()).findById(anyLong()); + verify(_vpnConnectionDao).releaseFromLockTable(VPN_CONNECTION_ID); + } + @Test public void testDeleteCustomerGatewaySuccess() { DeleteVpnCustomerGatewayCmd cmd = mock(DeleteVpnCustomerGatewayCmd.class); @@ -689,7 +746,6 @@ public void testDeleteVpnConnectionSuccess() throws ResourceUnavailableException DeleteVpnConnectionCmd cmd = mock(DeleteVpnConnectionCmd.class); when(cmd.getId()).thenReturn(VPN_CONNECTION_ID); - when(_vpnConnectionDao.findById(VPN_CONNECTION_ID)).thenReturn(vpnConnection); vpnConnection.setState(State.Pending); when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); @@ -700,8 +756,44 @@ public void testDeleteVpnConnectionSuccess() throws ResourceUnavailableException boolean result = site2SiteVpnManager.deleteVpnConnection(cmd); assertTrue(result); - verify(_vpnConnectionDao).remove(VPN_CONNECTION_ID); - verify(provider).deleteSite2SiteVpn(vpnConnection); + assertEquals(State.Removed, vpnConnection.getState()); + verify(_vpnConnectionDao, times(1)).acquireInLockTable(VPN_CONNECTION_ID); + InOrder lifecycle = inOrder(provider, _vpnConnectionDao, _vpcMgr); + lifecycle.verify(provider).deleteSite2SiteVpn(vpnConnection); + lifecycle.verify(_vpnConnectionDao).update(VPN_CONNECTION_ID, vpnConnection); + lifecycle.verify(_vpcMgr).applyStaticRouteForVpcVpnIfNeeded(VPC_ID, false); + lifecycle.verify(_vpnConnectionDao).remove(VPN_CONNECTION_ID); + lifecycle.verify(_vpnConnectionDao).releaseFromLockTable(VPN_CONNECTION_ID); + } + + @Test + public void testDeleteVpnConnectionProviderFailureKeepsRowAndReleasesLock() throws ResourceUnavailableException { + DeleteVpnConnectionCmd cmd = mock(DeleteVpnConnectionCmd.class); + when(cmd.getId()).thenReturn(VPN_CONNECTION_ID); + vpnConnection.setState(State.Connected); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + Site2SiteVpnServiceProvider provider = mockVpcVirtualRouterProvider(); + when(provider.deleteSite2SiteVpn(vpnConnection)).thenReturn(false); + + assertThrows(ResourceUnavailableException.class, + () -> site2SiteVpnManager.deleteVpnConnection(cmd)); + + assertEquals(State.Error, vpnConnection.getState()); + verify(_vpnConnectionDao, never()).update(VPN_CONNECTION_ID, vpnConnection); + verify(_vpnConnectionDao, never()).remove(VPN_CONNECTION_ID); + verify(_vpnConnectionDao).releaseFromLockTable(VPN_CONNECTION_ID); + } + + @Test + public void testDeleteVpnConnectionLockFailureIsNotReportedAsMissing() { + DeleteVpnConnectionCmd cmd = mock(DeleteVpnConnectionCmd.class); + when(cmd.getId()).thenReturn(VPN_CONNECTION_ID); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(null); + when(_vpnConnectionDao.findById(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + + assertThrows(CloudRuntimeException.class, + () -> site2SiteVpnManager.deleteVpnConnection(cmd)); } @Test @@ -737,6 +829,20 @@ public void testStartVpnConnectionProviderFailureLeavesConnectionInError() throw verify(_vpnConnectionDao, org.mockito.Mockito.atLeastOnce()).persist(vpnConnection); } + @Test + public void testStartVpnConnectionMissingGatewayLeavesConnectionInError() { + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + vpnConnection.setState(State.Pending); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(null); + + assertThrows(CloudRuntimeException.class, + () -> site2SiteVpnManager.startVpnConnection(VPN_CONNECTION_ID)); + + assertEquals(State.Error, vpnConnection.getState()); + verify(_vpnConnectionDao, times(2)).persist(vpnConnection); + verify(_vpnConnectionDao).releaseFromLockTable(VPN_CONNECTION_ID); + } + @Test public void testGatewayLifecycleUsesPersistedProviderWhenOfferingChanges() throws ResourceUnavailableException { when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); @@ -757,6 +863,30 @@ public void testGatewayLifecycleUsesPersistedProviderWhenOfferingChanges() throw verify(replacementProvider, never()).startSite2SiteVpn(any(Site2SiteVpnConnection.class)); } + @Test + public void testUnmarkedGatewayUsesLegacyVirtualRouterWhenOfferingNowUsesNsx() throws ResourceUnavailableException { + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + Site2SiteVpnServiceProvider nsxProvider = mock(Site2SiteVpnServiceProvider.class, + Mockito.withSettings().extraInterfaces(NetworkElement.class)); + Site2SiteVpnServiceProvider virtualRouterProvider = mock(Site2SiteVpnServiceProvider.class, + Mockito.withSettings().extraInterfaces(NetworkElement.class)); + when(((NetworkElement) nsxProvider).getProvider()).thenReturn(Network.Provider.Nsx); + when(((NetworkElement) virtualRouterProvider).getProvider()).thenReturn(Network.Provider.VPCVirtualRouter); + when(_vpcMgr.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, Network.Provider.Nsx)) + .thenReturn(true); + when(nsxProvider.ownsVpnGateway(vpnGateway)).thenReturn(false); + when(virtualRouterProvider.ownsVpnGateway(vpnGateway)).thenReturn(false); + when(_s2sProviders.iterator()).thenAnswer(invocation -> List.of(nsxProvider, virtualRouterProvider).iterator()); + when(virtualRouterProvider.startSite2SiteVpn(vpnConnection)).thenReturn(true); + when(_vpnConnectionDao.persist(any(Site2SiteVpnConnectionVO.class))).thenReturn(vpnConnection); + + site2SiteVpnManager.startVpnConnection(VPN_CONNECTION_ID); + + verify(virtualRouterProvider).startSite2SiteVpn(vpnConnection); + verify(nsxProvider, never()).startSite2SiteVpn(any(Site2SiteVpnConnection.class)); + } + @Test(expected = InvalidParameterValueException.class) public void testStartVpnConnectionWrongState() throws ResourceUnavailableException { when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); @@ -770,7 +900,6 @@ public void testResetVpnConnectionSuccess() throws ResourceUnavailableException ResetVpnConnectionCmd cmd = mock(ResetVpnConnectionCmd.class); when(cmd.getId()).thenReturn(VPN_CONNECTION_ID); - when(_vpnConnectionDao.findById(VPN_CONNECTION_ID)).thenReturn(vpnConnection); vpnConnection.setState(State.Connected); when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); @@ -783,6 +912,96 @@ public void testResetVpnConnectionSuccess() throws ResourceUnavailableException Site2SiteVpnConnection result = site2SiteVpnManager.resetVpnConnection(cmd); assertNotNull(result); + assertEquals(State.Connecting, result.getState()); + verify(_vpnConnectionDao, times(1)).acquireInLockTable(VPN_CONNECTION_ID); + InOrder lifecycle = inOrder(provider, _vpnConnectionDao, _vpcMgr); + lifecycle.verify(provider).stopSite2SiteVpn(vpnConnection); + lifecycle.verify(_vpcMgr).applyStaticRouteForVpcVpnIfNeeded(VPC_ID, false); + lifecycle.verify(provider).startSite2SiteVpn(vpnConnection); + lifecycle.verify(_vpnConnectionDao).releaseFromLockTable(VPN_CONNECTION_ID); + } + + @Test + public void testResetVpnConnectionStartFailureLeavesErrorAndReleasesLock() throws ResourceUnavailableException { + ResetVpnConnectionCmd cmd = mock(ResetVpnConnectionCmd.class); + when(cmd.getId()).thenReturn(VPN_CONNECTION_ID); + vpnConnection.setState(State.Connected); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + Site2SiteVpnServiceProvider provider = mockVpcVirtualRouterProvider(); + when(provider.stopSite2SiteVpn(vpnConnection)).thenReturn(true); + when(provider.startSite2SiteVpn(vpnConnection)).thenReturn(false); + when(_vpcMgr.applyStaticRouteForVpcVpnIfNeeded(VPC_ID, false)).thenReturn(true); + + assertThrows(ResourceUnavailableException.class, + () -> site2SiteVpnManager.resetVpnConnection(cmd)); + + assertEquals(State.Error, vpnConnection.getState()); + verify(_vpnConnectionDao, times(1)).acquireInLockTable(VPN_CONNECTION_ID); + verify(_vpnConnectionDao).releaseFromLockTable(VPN_CONNECTION_ID); + } + + @Test + public void testResetVpnConnectionAllowsPendingConnection() throws ResourceUnavailableException { + ResetVpnConnectionCmd cmd = mock(ResetVpnConnectionCmd.class); + when(cmd.getId()).thenReturn(VPN_CONNECTION_ID); + vpnConnection.setState(State.Pending); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + Site2SiteVpnServiceProvider provider = mockVpcVirtualRouterProvider(); + when(provider.stopSite2SiteVpn(vpnConnection)).thenReturn(true); + when(provider.startSite2SiteVpn(vpnConnection)).thenReturn(true); + + Site2SiteVpnConnection result = site2SiteVpnManager.resetVpnConnection(cmd); + + assertEquals(State.Connecting, result.getState()); + verify(provider).stopSite2SiteVpn(vpnConnection); + verify(provider).startSite2SiteVpn(vpnConnection); + verify(_vpnConnectionDao).releaseFromLockTable(VPN_CONNECTION_ID); + } + + @Test + public void testResetVpnConnectionStopFailureDoesNotStartAndReleasesLock() throws ResourceUnavailableException { + ResetVpnConnectionCmd cmd = mock(ResetVpnConnectionCmd.class); + when(cmd.getId()).thenReturn(VPN_CONNECTION_ID); + vpnConnection.setState(State.Connected); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + Site2SiteVpnServiceProvider provider = mockVpcVirtualRouterProvider(); + when(provider.stopSite2SiteVpn(vpnConnection)).thenReturn(false); + + assertThrows(ResourceUnavailableException.class, + () -> site2SiteVpnManager.resetVpnConnection(cmd)); + + assertEquals(State.Error, vpnConnection.getState()); + verify(provider, never()).startSite2SiteVpn(vpnConnection); + verify(_vpnConnectionDao).releaseFromLockTable(VPN_CONNECTION_ID); + } + + @Test + public void testResetVpnConnectionAccessDeniedReleasesLock() { + ResetVpnConnectionCmd cmd = mock(ResetVpnConnectionCmd.class); + when(cmd.getId()).thenReturn(VPN_CONNECTION_ID); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + Mockito.doThrow(new PermissionDeniedException("denied")) + .when(_accountMgr).checkAccess(account, null, false, vpnConnection); + + assertThrows(PermissionDeniedException.class, + () -> site2SiteVpnManager.resetVpnConnection(cmd)); + + verify(_vpnConnectionDao).releaseFromLockTable(VPN_CONNECTION_ID); + verify(_vpnGatewayDao, never()).findById(anyLong()); + } + + @Test + public void testResetVpnConnectionLockFailureIsNotReportedAsMissing() { + ResetVpnConnectionCmd cmd = mock(ResetVpnConnectionCmd.class); + when(cmd.getId()).thenReturn(VPN_CONNECTION_ID); + when(_vpnConnectionDao.acquireInLockTable(VPN_CONNECTION_ID)).thenReturn(null); + when(_vpnConnectionDao.findById(VPN_CONNECTION_ID)).thenReturn(vpnConnection); + + assertThrows(CloudRuntimeException.class, + () -> site2SiteVpnManager.resetVpnConnection(cmd)); } @Test From 8d073eb69091aef8883db9754865ebfb6efea2bc Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:29:05 +0100 Subject: [PATCH 3/8] NSX: qualify Mockito retry assertion --- .../java/org/apache/cloudstack/service/NsxApiClientTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java index f046f5db75d6..93742fc9818e 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java @@ -951,7 +951,7 @@ public void testAddVpnConnectionRoutesRetriesMarkedForDeletion() { client.addVpnConnectionRoutes(TIER_1_GATEWAY_NAME, "connection-uuid", List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16"); - verify(staticRoutes, times(2)).patch(eq(TIER_1_GATEWAY_NAME), + verify(staticRoutes, Mockito.times(2)).patch(eq(TIER_1_GATEWAY_NAME), eq("cs-conn-connection-uuid-route0"), any(com.vmware.nsx_policy.model.StaticRoutes.class)); verify(natRules).patch(eq(TIER_1_GATEWAY_NAME), anyString(), eq("cs-conn-connection-uuid-nosnat0"), any(PolicyNatRule.class)); From 4cea137c21021c19f986731efbc4b87f4f46207e Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:30:25 +0100 Subject: [PATCH 4/8] NSX: harden VPN status polling --- .../dao/Site2SiteVpnConnectionDao.java | 3 + .../dao/Site2SiteVpnConnectionDaoImpl.java | 13 ++ .../Site2SiteVpnConnectionDaoImplTest.java | 63 +++++++++ .../cloudstack/service/NsxServiceImpl.java | 34 +++-- .../service/NsxServiceImplTest.java | 130 +++++++++++++++++- 5 files changed, 229 insertions(+), 14 deletions(-) create mode 100644 engine/schema/src/test/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImplTest.java diff --git a/engine/schema/src/main/java/com/cloud/network/dao/Site2SiteVpnConnectionDao.java b/engine/schema/src/main/java/com/cloud/network/dao/Site2SiteVpnConnectionDao.java index 469ef9a5cc9d..d53b730f123b 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/Site2SiteVpnConnectionDao.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/Site2SiteVpnConnectionDao.java @@ -18,6 +18,7 @@ import java.util.List; +import com.cloud.network.Site2SiteVpnConnection; import com.cloud.utils.db.GenericDao; public interface Site2SiteVpnConnectionDao extends GenericDao { @@ -27,6 +28,8 @@ public interface Site2SiteVpnConnectionDao extends GenericDao listByVpcId(long vpcId); + List listByStates(Site2SiteVpnConnection.State... states); + Site2SiteVpnConnectionVO findByVpnGatewayIdAndCustomerGatewayId(long vpnId, long customerId); Site2SiteVpnConnectionVO findByCustomerGatewayId(long customerId); diff --git a/engine/schema/src/main/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImpl.java index f9c5ce089645..701e207ecaa0 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImpl.java @@ -23,6 +23,7 @@ import org.springframework.stereotype.Component; +import com.cloud.network.Site2SiteVpnConnection; import com.cloud.utils.db.GenericDaoBase; import com.cloud.utils.db.JoinBuilder.JoinType; import com.cloud.utils.db.SearchBuilder; @@ -39,6 +40,7 @@ public class Site2SiteVpnConnectionDaoImpl extends GenericDaoBase AllFieldsSearch; private SearchBuilder VpcSearch; private SearchBuilder VpnGatewaySearch; + private SearchBuilder StateSearch; public Site2SiteVpnConnectionDaoImpl() { } @@ -55,6 +57,10 @@ protected void init() { VpnGatewaySearch.and("vpcId", VpnGatewaySearch.entity().getVpcId(), SearchCriteria.Op.EQ); VpcSearch.join("vpnGatewaySearch", VpnGatewaySearch, VpnGatewaySearch.entity().getId(), VpcSearch.entity().getVpnGatewayId(), JoinType.INNER); VpcSearch.done(); + + StateSearch = createSearchBuilder(); + StateSearch.and("state", StateSearch.entity().getState(), SearchCriteria.Op.IN); + StateSearch.done(); } @Override @@ -78,6 +84,13 @@ public List listByVpcId(long vpcId) { return listBy(sc); } + @Override + public List listByStates(Site2SiteVpnConnection.State... states) { + SearchCriteria sc = StateSearch.create(); + sc.setParameters("state", (Object[]) states); + return listBy(sc); + } + @Override public Site2SiteVpnConnectionVO findByVpnGatewayIdAndCustomerGatewayId(long vpnId, long customerId) { SearchCriteria sc = AllFieldsSearch.create(); diff --git a/engine/schema/src/test/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImplTest.java b/engine/schema/src/test/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImplTest.java new file mode 100644 index 000000000000..ffec95ab0719 --- /dev/null +++ b/engine/schema/src/test/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImplTest.java @@ -0,0 +1,63 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.network.dao; + +import java.util.List; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.network.Site2SiteVpnConnection; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class Site2SiteVpnConnectionDaoImplTest { + + @Spy + private Site2SiteVpnConnectionDaoImpl dao; + @Mock + private SearchBuilder stateSearch; + @Mock + private SearchCriteria searchCriteria; + + @Test + public void testListByStatesUsesStateSearchCriteria() { + ReflectionTestUtils.setField(dao, "StateSearch", stateSearch); + when(stateSearch.create()).thenReturn(searchCriteria); + doReturn(List.of()).when(dao).listBy(searchCriteria); + Site2SiteVpnConnection.State[] states = { + Site2SiteVpnConnection.State.Pending, + Site2SiteVpnConnection.State.Connecting, + Site2SiteVpnConnection.State.Connected, + Site2SiteVpnConnection.State.Disconnected + }; + + dao.listByStates(states); + + verify(searchCriteria).setParameters("state", (Object[]) states); + verify(dao).listBy(searchCriteria); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java index 17e0513f01df..a180525f7ceb 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java @@ -116,15 +116,14 @@ public class NsxServiceImpl extends ManagerBase implements NsxService, Configura @Override public boolean configure(String name, Map params) throws ConfigurationException { super.configure(name, params); - vpnStatusPollExecutor = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("Nsx-Vpn-Status-Poll")); return true; } @Override - public boolean start() { + public synchronized boolean start() { super.start(); - if (vpnStatusPollExecutor == null) { - throw new IllegalStateException("NSX VPN status poller was not configured"); + if (vpnStatusPollExecutor != null && !vpnStatusPollExecutor.isShutdown()) { + return true; } Integer configuredInterval = NSX_VPN_STATUS_POLL_INTERVAL.value(); int pollInterval = Objects.isNull(configuredInterval) ? VPN_STATUS_POLL_DEFAULT_INTERVAL : configuredInterval; @@ -133,14 +132,27 @@ public boolean start() { configuredInterval, NSX_VPN_STATUS_POLL_INTERVAL.key(), VPN_STATUS_POLL_MIN_INTERVAL, VPN_STATUS_POLL_DEFAULT_INTERVAL); pollInterval = VPN_STATUS_POLL_DEFAULT_INTERVAL; } - vpnStatusPollExecutor.scheduleWithFixedDelay(new VpnStatusPollTask(), pollInterval, pollInterval, TimeUnit.SECONDS); + ScheduledExecutorService executor = createVpnStatusPollExecutor(); + try { + executor.scheduleWithFixedDelay(new VpnStatusPollTask(), pollInterval, pollInterval, TimeUnit.SECONDS); + vpnStatusPollExecutor = executor; + } catch (RuntimeException e) { + executor.shutdownNow(); + throw e; + } return true; } + protected ScheduledExecutorService createVpnStatusPollExecutor() { + return Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("Nsx-Vpn-Status-Poll")); + } + @Override - public boolean stop() { - if (Objects.nonNull(vpnStatusPollExecutor)) { - vpnStatusPollExecutor.shutdownNow(); + public synchronized boolean stop() { + ScheduledExecutorService executor = vpnStatusPollExecutor; + vpnStatusPollExecutor = null; + if (Objects.nonNull(executor)) { + executor.shutdownNow(); } return super.stop(); } @@ -339,11 +351,9 @@ protected class VpnStatusPollTask extends ManagedContextRunnable { protected void runInContext() { try { Set polledConnectionIds = new HashSet<>(); - List connections = site2SiteVpnConnectionDao.listAll(); + List connections = site2SiteVpnConnectionDao.listByStates( + VPN_POLLED_STATES.toArray(new Site2SiteVpnConnection.State[0])); for (Site2SiteVpnConnectionVO connection : connections) { - if (!VPN_POLLED_STATES.contains(connection.getState())) { - continue; - } Site2SiteVpnGatewayVO vpnGateway = site2SiteVpnGatewayDao.findById(connection.getVpnGatewayId()); if (vpnGateway == null) { continue; diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java index a507c78f3ec8..63b6bb8194e6 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java @@ -50,7 +50,10 @@ import org.mockito.junit.MockitoJUnitRunner; import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; @@ -62,6 +65,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -233,6 +237,84 @@ protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, assertEquals(Site2SiteVpnConnection.State.Connected, transitionedState.get()); } + @Test + public void testPollVpnConnectionStatusTransitionsDown() { + Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); + VpcVO vpc = mock(VpcVO.class); + when(connection.getState()).thenReturn(Site2SiteVpnConnection.State.Connected); + AtomicReference transitionedState = new AtomicReference<>(); + + NsxServiceImpl service = new NsxServiceImpl() { + @Override + public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + return VPN_SESSION_STATUS_DOWN; + } + + @Override + protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, + Site2SiteVpnConnection.State observedState, + Site2SiteVpnConnection.State newState) { + transitionedState.set(newState); + } + }; + + service.pollVpnConnectionStatus(connection, vpc); + + assertEquals(Site2SiteVpnConnection.State.Disconnected, transitionedState.get()); + } + + @Test + public void testPollVpnConnectionStatusKeepsPendingConnectionWhenSessionIsNotFound() { + Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); + VpcVO vpc = mock(VpcVO.class); + when(connection.getState()).thenReturn(Site2SiteVpnConnection.State.Pending); + AtomicBoolean transitioned = new AtomicBoolean(); + + NsxServiceImpl service = new NsxServiceImpl() { + @Override + public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + return VPN_SESSION_STATUS_NOT_FOUND; + } + + @Override + protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, + Site2SiteVpnConnection.State observedState, + Site2SiteVpnConnection.State newState) { + transitioned.set(true); + } + }; + + service.pollVpnConnectionStatus(connection, vpc); + + assertFalse(transitioned.get()); + } + + @Test + public void testPollVpnConnectionStatusMarksMissingConnectedSessionAsError() { + Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); + VpcVO vpc = mock(VpcVO.class); + when(connection.getState()).thenReturn(Site2SiteVpnConnection.State.Connected); + AtomicReference transitionedState = new AtomicReference<>(); + + NsxServiceImpl service = new NsxServiceImpl() { + @Override + public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + return VPN_SESSION_STATUS_NOT_FOUND; + } + + @Override + protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, + Site2SiteVpnConnection.State observedState, + Site2SiteVpnConnection.State newState) { + transitionedState.set(newState); + } + }; + + service.pollVpnConnectionStatus(connection, vpc); + + assertEquals(Site2SiteVpnConnection.State.Error, transitionedState.get()); + } + @Test public void testPollVpnConnectionStatusDoesNotTransitionOnQueryFailure() { Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); @@ -308,12 +390,56 @@ public void testVpnStatusPollerUsesPersistedOwnershipAfterOfferingChanges() { verify(service).pollVpnConnectionStatus(connection, vpc); } + @Test + public void testVpnStatusPollerQueriesOnlyPollableStates() { + nsxService.new VpnStatusPollTask().runInContext(); + + verify(site2SiteVpnConnectionDao).listByStates( + Site2SiteVpnConnection.State.Pending, + Site2SiteVpnConnection.State.Connecting, + Site2SiteVpnConnection.State.Connected, + Site2SiteVpnConnection.State.Disconnected); + verify(site2SiteVpnConnectionDao, never()).listAll(); + } + + @Test + public void testVpnStatusPollerCanRestartInSameJvm() throws Exception { + ScheduledExecutorService firstExecutor = mock(ScheduledExecutorService.class); + ScheduledExecutorService secondExecutor = mock(ScheduledExecutorService.class); + AtomicInteger executorIndex = new AtomicInteger(); + NsxServiceImpl service = new NsxServiceImpl() { + @Override + protected ScheduledExecutorService createVpnStatusPollExecutor() { + return executorIndex.getAndIncrement() == 0 ? firstExecutor : secondExecutor; + } + }; + service.configure("NsxService", Map.of()); + try { + assertTrue(service.start()); + verify(firstExecutor).scheduleWithFixedDelay(any(Runnable.class), eq(60L), eq(60L), eq(java.util.concurrent.TimeUnit.SECONDS)); + + assertTrue(service.stop()); + verify(firstExecutor).shutdownNow(); + + assertTrue(service.start()); + verify(secondExecutor).scheduleWithFixedDelay(any(Runnable.class), eq(60L), eq(60L), eq(java.util.concurrent.TimeUnit.SECONDS)); + assertEquals(2, executorIndex.get()); + } finally { + service.stop(); + } + verify(secondExecutor).shutdownNow(); + verify(firstExecutor, times(1)).shutdownNow(); + } + private Site2SiteVpnConnectionVO mockPollableVpnConnection() { Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); when(connection.getId()).thenReturn(11L); when(connection.getVpnGatewayId()).thenReturn(7L); - when(connection.getState()).thenReturn(Site2SiteVpnConnection.State.Connected); - when(site2SiteVpnConnectionDao.listAll()).thenReturn(List.of(connection)); + when(site2SiteVpnConnectionDao.listByStates( + Site2SiteVpnConnection.State.Pending, + Site2SiteVpnConnection.State.Connecting, + Site2SiteVpnConnection.State.Connected, + Site2SiteVpnConnection.State.Disconnected)).thenReturn(List.of(connection)); return connection; } From e8b443af4f661d8fabdd2866d8cd41e65a248f81 Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:00:31 +0100 Subject: [PATCH 5/8] test: exercise legacy VPN gateway IP fallback --- .../vpn/Site2SiteVpnManagerImplTest.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java index 15b5156ff805..78511e144452 100644 --- a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java @@ -331,6 +331,7 @@ public void testCreateVpnGatewayNoSourceNatIp() { CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(cmd.getIpAddressId()).thenReturn(null); when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); @@ -340,6 +341,46 @@ public void testCreateVpnGatewayNoSourceNatIp() { site2SiteVpnManager.createVpnGateway(cmd); } + @Test + public void testCreateVpnGatewaySkipsSystemVmSourceNatIp() { + CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); + when(cmd.getVpcId()).thenReturn(VPC_ID); + when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(cmd.getIpAddressId()).thenReturn(null); + when(cmd.isDisplay()).thenReturn(true); + IPAddressVO systemVmIp = mock(IPAddressVO.class); + when(systemVmIp.isForSystemVms()).thenReturn(true); + when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + mockVpcVirtualRouterProvider(); + when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(List.of(systemVmIp, ipAddress)); + when(_vpnGatewayDao.persist(any(Site2SiteVpnGatewayVO.class))).thenReturn(vpnGateway); + + Site2SiteVpnGateway result = site2SiteVpnManager.createVpnGateway(cmd); + + assertNotNull(result); + ArgumentCaptor gatewayCaptor = ArgumentCaptor.forClass(Site2SiteVpnGatewayVO.class); + verify(_vpnGatewayDao).persist(gatewayCaptor.capture()); + assertEquals(IP_ADDRESS_ID.longValue(), gatewayCaptor.getValue().getAddrId()); + } + + @Test(expected = CloudRuntimeException.class) + public void testCreateVpnGatewayRejectsMultipleCustomerSourceNatIps() { + CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); + when(cmd.getVpcId()).thenReturn(VPC_ID); + when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(cmd.getIpAddressId()).thenReturn(null); + IPAddressVO secondIp = mock(IPAddressVO.class); + when(ipAddress.getAddress()).thenReturn(new Ip("203.0.113.34")); + when(secondIp.getAddress()).thenReturn(new Ip("203.0.113.35")); + when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + mockVpcVirtualRouterProvider(); + when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(List.of(ipAddress, secondIp)); + + site2SiteVpnManager.createVpnGateway(cmd); + } + @Test(expected = InvalidParameterValueException.class) public void testCreateCustomerGatewayInvalidIp() { CreateVpnCustomerGatewayCmd cmd = mock(CreateVpnCustomerGatewayCmd.class); From f07698c157668d35554928743e5010682ff7a625 Mon Sep 17 00:00:00 2001 From: Brad <100990646+Dogface2k@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:49:36 +0100 Subject: [PATCH 6/8] Revert "test: exercise legacy VPN gateway IP fallback" The added system-VM source-NAT test asserted behavior that the production legacy fallback does not implement, and the test also failed compilation due to a missing ArgumentCaptor import. Restore the previously validated test tree without changing production code. --- .../vpn/Site2SiteVpnManagerImplTest.java | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java index 78511e144452..15b5156ff805 100644 --- a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java @@ -331,7 +331,6 @@ public void testCreateVpnGatewayNoSourceNatIp() { CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); - when(cmd.getIpAddressId()).thenReturn(null); when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); @@ -341,46 +340,6 @@ public void testCreateVpnGatewayNoSourceNatIp() { site2SiteVpnManager.createVpnGateway(cmd); } - @Test - public void testCreateVpnGatewaySkipsSystemVmSourceNatIp() { - CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); - when(cmd.getVpcId()).thenReturn(VPC_ID); - when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); - when(cmd.getIpAddressId()).thenReturn(null); - when(cmd.isDisplay()).thenReturn(true); - IPAddressVO systemVmIp = mock(IPAddressVO.class); - when(systemVmIp.isForSystemVms()).thenReturn(true); - when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); - when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); - mockVpcVirtualRouterProvider(); - when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(List.of(systemVmIp, ipAddress)); - when(_vpnGatewayDao.persist(any(Site2SiteVpnGatewayVO.class))).thenReturn(vpnGateway); - - Site2SiteVpnGateway result = site2SiteVpnManager.createVpnGateway(cmd); - - assertNotNull(result); - ArgumentCaptor gatewayCaptor = ArgumentCaptor.forClass(Site2SiteVpnGatewayVO.class); - verify(_vpnGatewayDao).persist(gatewayCaptor.capture()); - assertEquals(IP_ADDRESS_ID.longValue(), gatewayCaptor.getValue().getAddrId()); - } - - @Test(expected = CloudRuntimeException.class) - public void testCreateVpnGatewayRejectsMultipleCustomerSourceNatIps() { - CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); - when(cmd.getVpcId()).thenReturn(VPC_ID); - when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); - when(cmd.getIpAddressId()).thenReturn(null); - IPAddressVO secondIp = mock(IPAddressVO.class); - when(ipAddress.getAddress()).thenReturn(new Ip("203.0.113.34")); - when(secondIp.getAddress()).thenReturn(new Ip("203.0.113.35")); - when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); - when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); - mockVpcVirtualRouterProvider(); - when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(List.of(ipAddress, secondIp)); - - site2SiteVpnManager.createVpnGateway(cmd); - } - @Test(expected = InvalidParameterValueException.class) public void testCreateCustomerGatewayInvalidIp() { CreateVpnCustomerGatewayCmd cmd = mock(CreateVpnCustomerGatewayCmd.class); From 950e3adaa5af51223b35faba2432ea28785dc12e Mon Sep 17 00:00:00 2001 From: Brad <100990646+Dogface2k@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:12:57 +0100 Subject: [PATCH 7/8] NSX: preserve VPN gateway ownership on retry Do not overwrite an existing nsxVpnGatewayIp detail when a requested endpoint is retried. Remove the marker on failure only when the current attempt created it, preserving ownership from an earlier ambiguous operation. Add focused tests for existing, temporary and ambiguous ownership-marker handling. Signed-off-by: Brad <100990646+Dogface2k@users.noreply.github.com> --- .../apache/cloudstack/service/NsxElement.java | 13 +- .../service/NsxVpnGatewayOwnershipTest.java | 147 ++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxVpnGatewayOwnershipTest.java diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java index 08149d5f933e..ace525e669f6 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java @@ -1016,10 +1016,17 @@ public IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { ip = allocateVpnGatewayIp(vpc); autoAcquired = true; } + boolean requestedIpOwnershipMarkerAdded = false; if (!autoAcquired) { try { - // Mark ownership first so ambiguous NSX responses remain recoverable. - userIpAddressDetailsDao.addDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL, "false", false); + // Preserve an earlier ownership marker: it may represent an ambiguous create that + // still requires provider-side cleanup and must not be downgraded on a retry. + UserIpAddressDetailVO existingOwnershipMarker = userIpAddressDetailsDao.findDetail( + ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL); + if (existingOwnershipMarker == null) { + userIpAddressDetailsDao.addDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL, "false", false); + requestedIpOwnershipMarkerAdded = true; + } } catch (Exception e) { throw new CloudRuntimeException(String.format( "Failed to record NSX VPN ownership for requested IP %s of VPC %s", @@ -1045,7 +1052,7 @@ public IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { } else if (autoAcquired) { logger.warn("Retaining auto-acquired VPN gateway IP {} for VPC {} because the NSX endpoint may still be using it", ip.getAddress(), vpc.getName()); - } else if (!endpointMayBeInUse) { + } else if (!endpointMayBeInUse && requestedIpOwnershipMarkerAdded) { try { userIpAddressDetailsDao.removeDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL); } catch (Exception cleanupException) { diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxVpnGatewayOwnershipTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxVpnGatewayOwnershipTest.java new file mode 100644 index 000000000000..4161e545c18d --- /dev/null +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxVpnGatewayOwnershipTest.java @@ -0,0 +1,147 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.service; + +import com.cloud.network.IpAddress; +import com.cloud.network.Network; +import com.cloud.network.dao.FirewallRulesDao; +import com.cloud.network.dao.IPAddressDao; +import com.cloud.network.dao.IPAddressVO; +import com.cloud.network.dao.LoadBalancerDao; +import com.cloud.network.nsx.NsxVpnGatewayResult; +import com.cloud.network.rules.dao.PortForwardingRulesDao; +import com.cloud.network.vpc.VpcManager; +import com.cloud.network.vpc.VpcVO; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.net.Ip; +import org.apache.cloudstack.resourcedetail.UserIpAddressDetailVO; +import org.apache.cloudstack.resourcedetail.dao.UserIpAddressDetailsDao; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class NsxVpnGatewayOwnershipTest { + + private static final long VPC_ID = 9L; + private static final long IP_ADDRESS_ID = 20L; + private static final String IP_ADDRESS = "203.0.113.20"; + + @Mock + private NsxServiceImpl nsxService; + @Mock + private VpcManager vpcManager; + @Mock + private IPAddressDao ipAddressDao; + @Mock + private UserIpAddressDetailsDao userIpAddressDetailsDao; + @Mock + private FirewallRulesDao firewallRulesDao; + @Mock + private PortForwardingRulesDao portForwardingRulesDao; + @Mock + private LoadBalancerDao loadBalancerDao; + @Mock + private VpcVO vpc; + @Mock + private IpAddress requestedIp; + @Mock + private IPAddressVO ipAddress; + + private NsxElement element; + + @Before + public void setUp() { + element = new NsxElement(); + element.nsxService = nsxService; + element.vpcManager = vpcManager; + element.ipAddressDao = ipAddressDao; + element.userIpAddressDetailsDao = userIpAddressDetailsDao; + element.firewallRulesDao = firewallRulesDao; + element.portForwardingRulesDao = portForwardingRulesDao; + element.loadBalancerDao = loadBalancerDao; + + when(vpc.getId()).thenReturn(VPC_ID); + when(vpc.getName()).thenReturn("vpc"); + when(vpcManager.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, Network.Provider.Nsx)) + .thenReturn(true); + when(requestedIp.getId()).thenReturn(IP_ADDRESS_ID); + when(ipAddressDao.findById(IP_ADDRESS_ID)).thenReturn(ipAddress); + when(ipAddress.getId()).thenReturn(IP_ADDRESS_ID); + when(ipAddress.getVpcId()).thenReturn(VPC_ID); + when(ipAddress.readyToUse()).thenReturn(true); + when(ipAddress.getAddress()).thenReturn(new Ip(IP_ADDRESS)); + when(firewallRulesDao.listByIpAndNotRevoked(IP_ADDRESS_ID)).thenReturn(List.of()); + when(portForwardingRulesDao.listByIpAndNotRevoked(IP_ADDRESS_ID)).thenReturn(List.of()); + when(loadBalancerDao.listByIpAddress(IP_ADDRESS_ID)).thenReturn(List.of()); + } + + @Test + public void testExistingOwnershipMarkerIsNotOverwrittenOrRemovedOnFailure() { + UserIpAddressDetailVO existingDetail = new UserIpAddressDetailVO( + IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL, "true", false); + when(userIpAddressDetailsDao.findDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL)) + .thenReturn(existingDetail); + when(nsxService.createVpnGateway(vpc, IP_ADDRESS)).thenReturn(new NsxVpnGatewayResult(false, false)); + + Assert.assertThrows(CloudRuntimeException.class, + () -> element.acquireVpnGatewayIp(vpc, requestedIp)); + + verify(userIpAddressDetailsDao, never()).addDetail(anyLong(), anyString(), anyString(), anyBoolean()); + verify(userIpAddressDetailsDao, never()).removeDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL); + } + + @Test + public void testTemporaryOwnershipMarkerIsRemovedAfterUnambiguousFailure() { + when(userIpAddressDetailsDao.findDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL)) + .thenReturn(null); + when(nsxService.createVpnGateway(vpc, IP_ADDRESS)).thenReturn(new NsxVpnGatewayResult(false, false)); + + Assert.assertThrows(CloudRuntimeException.class, + () -> element.acquireVpnGatewayIp(vpc, requestedIp)); + + verify(userIpAddressDetailsDao).addDetail( + IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL, "false", false); + verify(userIpAddressDetailsDao).removeDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL); + } + + @Test + public void testTemporaryOwnershipMarkerIsRetainedAfterAmbiguousFailure() { + when(userIpAddressDetailsDao.findDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL)) + .thenReturn(null); + when(nsxService.createVpnGateway(vpc, IP_ADDRESS)).thenReturn(new NsxVpnGatewayResult(false, true)); + + Assert.assertThrows(CloudRuntimeException.class, + () -> element.acquireVpnGatewayIp(vpc, requestedIp)); + + verify(userIpAddressDetailsDao).addDetail( + IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL, "false", false); + verify(userIpAddressDetailsDao, never()).removeDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL); + } +} From a70061fef3dc040b31e72cdbf23b22c44f994a16 Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:33:49 +0100 Subject: [PATCH 8/8] NSX: complete native VPN lifecycle hardening Refine native route-based site-to-site VPN across the provider SPI, management and agent boundary, Tier-1 lifecycle, status polling, API output, and UI details. Preserve provider ownership and database state when NSX lookup or teardown fails, treat only SDK NotFound as absence, fail closed on ambiguous operations, and keep legacy virtual-router dispatch isolated. Consolidate boundary-focused coverage for rollback, deletion, redaction, and provider failure behavior while removing duplicate and implementation-coupled tests. Signed-off-by: Dogface2k <100990646+Dogface2k@users.noreply.github.com> --- .../network/Site2SiteVpnTunnelInterface.java | 41 +++ .../element/Site2SiteVpnServiceProvider.java | 9 + .../com/cloud/network/nsx/NsxService.java | 9 +- .../apache/cloudstack/api/ApiConstants.java | 3 + .../Site2SiteVpnConnectionResponse.java | 36 ++ .../Site2SiteVpnConnectionDaoImplTest.java | 63 ---- .../api/CreateNsxVpnConnectionCommand.java | 17 +- .../agent/api/CreateNsxVpnGatewayCommand.java | 17 +- .../api/DeleteNsxVpnConnectionCommand.java | 16 +- .../api/GetNsxVpnSessionStatusCommand.java | 16 +- .../UpdateNsxVpnConnectionStateCommand.java | 17 +- .../cloudstack/resource/NsxResource.java | 56 ++-- .../cloudstack/service/NsxApiClient.java | 234 +++++++------ .../apache/cloudstack/service/NsxElement.java | 106 ++++-- .../cloudstack/service/NsxServiceImpl.java | 81 +++-- .../cloudstack/utils/NsxControllerUtils.java | 46 ++- .../cloudstack/resource/NsxResourceTest.java | 67 ++-- .../cloudstack/service/NsxApiClientTest.java | 309 +++++++++++++----- .../cloudstack/service/NsxElementTest.java | 133 ++++++-- .../service/NsxServiceImplTest.java | 99 +++++- .../service/NsxVpnGatewayOwnershipTest.java | 147 --------- .../utils/NsxControllerUtilsTest.java | 13 + .../java/com/cloud/api/ApiResponseHelper.java | 9 + .../vpn/RemoteAccessVpnManagerImpl.java | 4 +- .../network/vpn/Site2SiteVpnManager.java | 4 + .../network/vpn/Site2SiteVpnManagerImpl.java | 14 + .../com/cloud/api/ApiResponseHelperTest.java | 25 ++ .../vpn/RemoteAccessVpnManagerImplTest.java | 70 ++-- .../vpn/Site2SiteVpnManagerImplTest.java | 17 + .../vpc/MockSite2SiteVpnManagerImpl.java | 6 + ui/public/locales/en.json | 3 + ui/src/config/section/network.js | 2 +- 32 files changed, 1061 insertions(+), 628 deletions(-) create mode 100644 api/src/main/java/com/cloud/network/Site2SiteVpnTunnelInterface.java delete mode 100644 engine/schema/src/test/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImplTest.java delete mode 100644 plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxVpnGatewayOwnershipTest.java diff --git a/api/src/main/java/com/cloud/network/Site2SiteVpnTunnelInterface.java b/api/src/main/java/com/cloud/network/Site2SiteVpnTunnelInterface.java new file mode 100644 index 000000000000..0940dda61005 --- /dev/null +++ b/api/src/main/java/com/cloud/network/Site2SiteVpnTunnelInterface.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.network; + +public class Site2SiteVpnTunnelInterface { + private final String localIp; + private final String peerIp; + private final int prefixLength; + + public Site2SiteVpnTunnelInterface(String localIp, String peerIp, int prefixLength) { + this.localIp = localIp; + this.peerIp = peerIp; + this.prefixLength = prefixLength; + } + + public String getLocalIp() { + return localIp; + } + + public String getPeerIp() { + return peerIp; + } + + public int getPrefixLength() { + return prefixLength; + } +} diff --git a/api/src/main/java/com/cloud/network/element/Site2SiteVpnServiceProvider.java b/api/src/main/java/com/cloud/network/element/Site2SiteVpnServiceProvider.java index 40829a5c5a7a..fde7b7ddb849 100644 --- a/api/src/main/java/com/cloud/network/element/Site2SiteVpnServiceProvider.java +++ b/api/src/main/java/com/cloud/network/element/Site2SiteVpnServiceProvider.java @@ -21,6 +21,7 @@ import com.cloud.network.Site2SiteCustomerGateway; import com.cloud.network.Site2SiteVpnConnection; import com.cloud.network.Site2SiteVpnGateway; +import com.cloud.network.Site2SiteVpnTunnelInterface; import com.cloud.network.vpc.Vpc; import com.cloud.utils.component.Adapter; @@ -68,4 +69,12 @@ default void releaseVpnGatewayIp(Site2SiteVpnGateway gateway) { default boolean ownsVpnGateway(Site2SiteVpnGateway gateway) { return false; } + + /** + * Returns provider-specific route-based tunnel addressing that the peer must configure. + * Policy-based providers return no tunnel-interface details. + */ + default Site2SiteVpnTunnelInterface getSite2SiteVpnTunnelInterface(Site2SiteVpnConnection connection) { + return null; + } } diff --git a/api/src/main/java/com/cloud/network/nsx/NsxService.java b/api/src/main/java/com/cloud/network/nsx/NsxService.java index 17dcd7465cf5..dd3706622115 100644 --- a/api/src/main/java/com/cloud/network/nsx/NsxService.java +++ b/api/src/main/java/com/cloud/network/nsx/NsxService.java @@ -39,12 +39,13 @@ public interface NsxService { String getSegmentId(long domainId, long accountId, long zoneId, Long vpcId, long networkId); NsxVpnGatewayResult createVpnGateway(Vpc vpc, String localEndpointIp); + NsxVpnGatewayResult createVpnGateway(Vpc vpc, String localEndpointIp, boolean reconcileExistingService); boolean deleteVpnGateway(Vpc vpc); - boolean createVpnConnection(Vpc vpc, String connectionUuid, String peerAddress, String psk, + boolean createVpnConnection(Vpc vpc, long connectionId, String peerAddress, String psk, String ikePolicy, String espPolicy, Long ikeLifetime, Long espLifetime, boolean dpdEnabled, String ikeVersion, boolean passive, List peerCidrs, String vtiLocalIp, String vtiPeerIp, int vtiPrefixLength, String localEndpointIp); - boolean deleteVpnConnection(Vpc vpc, String connectionUuid); - boolean updateVpnConnectionState(Vpc vpc, String connectionUuid, boolean enabled); - String getVpnConnectionStatus(Vpc vpc, String connectionUuid); + boolean deleteVpnConnection(Vpc vpc, long connectionId); + boolean updateVpnConnectionState(Vpc vpc, long connectionId, boolean enabled); + String getVpnConnectionStatus(Vpc vpc, long connectionId); } diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index ac6acdf42516..fcee5774ecd5 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -1034,6 +1034,9 @@ public class ApiConstants { public static final String CUSTOMER = "customer"; public static final String S2S_VPN_GATEWAY_ID = "s2svpngatewayid"; public static final String S2S_CUSTOMER_GATEWAY_ID = "s2scustomergatewayid"; + public static final String LOCAL_VTI_IP = "localvtiip"; + public static final String PEER_VTI_IP = "peervtiip"; + public static final String VTI_PREFIX_LENGTH = "vtiprefixlength"; public static final String IPSEC_PSK = "ipsecpsk"; public static final String GUEST_IP = "guestip"; public static final String REMOVED = "removed"; diff --git a/api/src/main/java/org/apache/cloudstack/api/response/Site2SiteVpnConnectionResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/Site2SiteVpnConnectionResponse.java index e668b7d948fd..9fd53c4e83e5 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/Site2SiteVpnConnectionResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/Site2SiteVpnConnectionResponse.java @@ -144,6 +144,18 @@ public class Site2SiteVpnConnectionResponse extends BaseResponse implements Cont @Param(description = "Which IKE Version to use, one of ike (autoselect), IKEv1, or IKEv2. Defaults to ike") private String ikeVersion; + @SerializedName(ApiConstants.LOCAL_VTI_IP) + @Param(description = "The provider-side virtual tunnel interface IP address", since = "4.23.0.0") + private String localVtiIp; + + @SerializedName(ApiConstants.PEER_VTI_IP) + @Param(description = "The peer-side virtual tunnel interface IP address", since = "4.23.0.0") + private String peerVtiIp; + + @SerializedName(ApiConstants.VTI_PREFIX_LENGTH) + @Param(description = "The virtual tunnel interface network prefix length", since = "4.23.0.0") + private Integer vtiPrefixLength; + public void setId(String id) { this.id = id; } @@ -220,6 +232,30 @@ public void setIkeVersion(String ikeVersion) { this.ikeVersion = ikeVersion; } + public String getLocalVtiIp() { + return localVtiIp; + } + + public void setLocalVtiIp(String localVtiIp) { + this.localVtiIp = localVtiIp; + } + + public String getPeerVtiIp() { + return peerVtiIp; + } + + public void setPeerVtiIp(String peerVtiIp) { + this.peerVtiIp = peerVtiIp; + } + + public Integer getVtiPrefixLength() { + return vtiPrefixLength; + } + + public void setVtiPrefixLength(Integer vtiPrefixLength) { + this.vtiPrefixLength = vtiPrefixLength; + } + @Override public void setAccountName(String accountName) { this.accountName = accountName; diff --git a/engine/schema/src/test/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImplTest.java b/engine/schema/src/test/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImplTest.java deleted file mode 100644 index ffec95ab0719..000000000000 --- a/engine/schema/src/test/java/com/cloud/network/dao/Site2SiteVpnConnectionDaoImplTest.java +++ /dev/null @@ -1,63 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. -package com.cloud.network.dao; - -import java.util.List; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.MockitoJUnitRunner; -import org.springframework.test.util.ReflectionTestUtils; - -import com.cloud.network.Site2SiteVpnConnection; -import com.cloud.utils.db.SearchBuilder; -import com.cloud.utils.db.SearchCriteria; - -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@RunWith(MockitoJUnitRunner.class) -public class Site2SiteVpnConnectionDaoImplTest { - - @Spy - private Site2SiteVpnConnectionDaoImpl dao; - @Mock - private SearchBuilder stateSearch; - @Mock - private SearchCriteria searchCriteria; - - @Test - public void testListByStatesUsesStateSearchCriteria() { - ReflectionTestUtils.setField(dao, "StateSearch", stateSearch); - when(stateSearch.create()).thenReturn(searchCriteria); - doReturn(List.of()).when(dao).listBy(searchCriteria); - Site2SiteVpnConnection.State[] states = { - Site2SiteVpnConnection.State.Pending, - Site2SiteVpnConnection.State.Connecting, - Site2SiteVpnConnection.State.Connected, - Site2SiteVpnConnection.State.Disconnected - }; - - dao.listByStates(states); - - verify(searchCriteria).setParameters("state", (Object[]) states); - verify(dao).listBy(searchCriteria); - } -} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnConnectionCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnConnectionCommand.java index 050e475cd828..8bb57992a216 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnConnectionCommand.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnConnectionCommand.java @@ -25,7 +25,7 @@ public class CreateNsxVpnConnectionCommand extends NsxCommand { private Long vpcId; private String vpcName; - private String connectionUuid; + private long connectionId; private String peerAddress; @LogLevel(LogLevel.Log4jLevel.Off) private String psk; @@ -44,7 +44,7 @@ public class CreateNsxVpnConnectionCommand extends NsxCommand { private String localEndpointIp; public CreateNsxVpnConnectionCommand(long domainId, long accountId, long zoneId, - Long vpcId, String vpcName, String connectionUuid, String peerAddress, + Long vpcId, String vpcName, long connectionId, String peerAddress, String psk, String ikePolicy, String espPolicy, Long ikeLifetime, Long espLifetime, boolean dpdEnabled, String ikeVersion, boolean passive, List peerCidrs, String vtiLocalIp, String vtiPeerIp, @@ -52,7 +52,7 @@ public CreateNsxVpnConnectionCommand(long domainId, long accountId, long zoneId, super(domainId, accountId, zoneId); this.vpcId = vpcId; this.vpcName = vpcName; - this.connectionUuid = connectionUuid; + this.connectionId = connectionId; this.peerAddress = peerAddress; this.psk = psk; this.ikePolicy = ikePolicy; @@ -78,8 +78,8 @@ public String getVpcName() { return vpcName; } - public String getConnectionUuid() { - return connectionUuid; + public long getConnectionId() { + return connectionId; } public String getPeerAddress() { @@ -151,9 +151,10 @@ public boolean equals(Object o) { return false; } CreateNsxVpnConnectionCommand that = (CreateNsxVpnConnectionCommand) o; - return dpdEnabled == that.dpdEnabled && passive == that.passive && vtiPrefixLength == that.vtiPrefixLength + return connectionId == that.connectionId && dpdEnabled == that.dpdEnabled && passive == that.passive + && vtiPrefixLength == that.vtiPrefixLength && Objects.equals(vpcId, that.vpcId) && Objects.equals(vpcName, that.vpcName) - && Objects.equals(connectionUuid, that.connectionUuid) && Objects.equals(peerAddress, that.peerAddress) + && Objects.equals(peerAddress, that.peerAddress) && Objects.equals(psk, that.psk) && Objects.equals(ikePolicy, that.ikePolicy) && Objects.equals(espPolicy, that.espPolicy) && Objects.equals(ikeLifetime, that.ikeLifetime) && Objects.equals(espLifetime, that.espLifetime) && Objects.equals(ikeVersion, that.ikeVersion) @@ -164,7 +165,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(super.hashCode(), vpcId, vpcName, connectionUuid, peerAddress, psk, ikePolicy, espPolicy, + return Objects.hash(super.hashCode(), vpcId, vpcName, connectionId, peerAddress, psk, ikePolicy, espPolicy, ikeLifetime, espLifetime, dpdEnabled, ikeVersion, passive, peerCidrs, vtiLocalIp, vtiPeerIp, vtiPrefixLength, vpcCidr, localEndpointIp); } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnGatewayCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnGatewayCommand.java index 9a381f5cb4b9..5b231ea571f7 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnGatewayCommand.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxVpnGatewayCommand.java @@ -23,13 +23,21 @@ public class CreateNsxVpnGatewayCommand extends NsxCommand { private Long vpcId; private String vpcName; private String localEndpointIp; + private boolean reconcileExistingService; public CreateNsxVpnGatewayCommand(long domainId, long accountId, long zoneId, Long vpcId, String vpcName, String localEndpointIp) { + this(domainId, accountId, zoneId, vpcId, vpcName, localEndpointIp, false); + } + + public CreateNsxVpnGatewayCommand(long domainId, long accountId, long zoneId, + Long vpcId, String vpcName, String localEndpointIp, + boolean reconcileExistingService) { super(domainId, accountId, zoneId); this.vpcId = vpcId; this.vpcName = vpcName; this.localEndpointIp = localEndpointIp; + this.reconcileExistingService = reconcileExistingService; } public Long getVpcId() { @@ -44,6 +52,10 @@ public String getLocalEndpointIp() { return localEndpointIp; } + public boolean isReconcileExistingService() { + return reconcileExistingService; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -53,11 +65,12 @@ public boolean equals(Object o) { return false; } CreateNsxVpnGatewayCommand that = (CreateNsxVpnGatewayCommand) o; - return Objects.equals(vpcId, that.vpcId) && Objects.equals(vpcName, that.vpcName) && Objects.equals(localEndpointIp, that.localEndpointIp); + return reconcileExistingService == that.reconcileExistingService && Objects.equals(vpcId, that.vpcId) + && Objects.equals(vpcName, that.vpcName) && Objects.equals(localEndpointIp, that.localEndpointIp); } @Override public int hashCode() { - return Objects.hash(super.hashCode(), vpcId, vpcName, localEndpointIp); + return Objects.hash(super.hashCode(), vpcId, vpcName, localEndpointIp, reconcileExistingService); } } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnConnectionCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnConnectionCommand.java index 83b0644e695f..b7386af2d965 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnConnectionCommand.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/DeleteNsxVpnConnectionCommand.java @@ -22,14 +22,14 @@ public class DeleteNsxVpnConnectionCommand extends NsxCommand { private Long vpcId; private String vpcName; - private String connectionUuid; + private long connectionId; public DeleteNsxVpnConnectionCommand(long domainId, long accountId, long zoneId, - Long vpcId, String vpcName, String connectionUuid) { + Long vpcId, String vpcName, long connectionId) { super(domainId, accountId, zoneId); this.vpcId = vpcId; this.vpcName = vpcName; - this.connectionUuid = connectionUuid; + this.connectionId = connectionId; } public Long getVpcId() { @@ -40,8 +40,8 @@ public String getVpcName() { return vpcName; } - public String getConnectionUuid() { - return connectionUuid; + public long getConnectionId() { + return connectionId; } @Override @@ -53,12 +53,12 @@ public boolean equals(Object o) { return false; } DeleteNsxVpnConnectionCommand that = (DeleteNsxVpnConnectionCommand) o; - return Objects.equals(vpcId, that.vpcId) && Objects.equals(vpcName, that.vpcName) - && Objects.equals(connectionUuid, that.connectionUuid); + return connectionId == that.connectionId && Objects.equals(vpcId, that.vpcId) + && Objects.equals(vpcName, that.vpcName); } @Override public int hashCode() { - return Objects.hash(super.hashCode(), vpcId, vpcName, connectionUuid); + return Objects.hash(super.hashCode(), vpcId, vpcName, connectionId); } } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/GetNsxVpnSessionStatusCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/GetNsxVpnSessionStatusCommand.java index 0aed9e33f8f7..394957092590 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/GetNsxVpnSessionStatusCommand.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/GetNsxVpnSessionStatusCommand.java @@ -22,14 +22,14 @@ public class GetNsxVpnSessionStatusCommand extends NsxCommand { private Long vpcId; private String vpcName; - private String connectionUuid; + private long connectionId; public GetNsxVpnSessionStatusCommand(long domainId, long accountId, long zoneId, - Long vpcId, String vpcName, String connectionUuid) { + Long vpcId, String vpcName, long connectionId) { super(domainId, accountId, zoneId); this.vpcId = vpcId; this.vpcName = vpcName; - this.connectionUuid = connectionUuid; + this.connectionId = connectionId; } public Long getVpcId() { @@ -40,8 +40,8 @@ public String getVpcName() { return vpcName; } - public String getConnectionUuid() { - return connectionUuid; + public long getConnectionId() { + return connectionId; } @Override @@ -53,12 +53,12 @@ public boolean equals(Object o) { return false; } GetNsxVpnSessionStatusCommand that = (GetNsxVpnSessionStatusCommand) o; - return Objects.equals(vpcId, that.vpcId) && Objects.equals(vpcName, that.vpcName) - && Objects.equals(connectionUuid, that.connectionUuid); + return connectionId == that.connectionId && Objects.equals(vpcId, that.vpcId) + && Objects.equals(vpcName, that.vpcName); } @Override public int hashCode() { - return Objects.hash(super.hashCode(), vpcId, vpcName, connectionUuid); + return Objects.hash(super.hashCode(), vpcId, vpcName, connectionId); } } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/UpdateNsxVpnConnectionStateCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/UpdateNsxVpnConnectionStateCommand.java index 541065f86a04..c9492eb3a407 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/UpdateNsxVpnConnectionStateCommand.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/UpdateNsxVpnConnectionStateCommand.java @@ -22,16 +22,16 @@ public class UpdateNsxVpnConnectionStateCommand extends NsxCommand { private Long vpcId; private String vpcName; - private String connectionUuid; + private long connectionId; private boolean enabled; public UpdateNsxVpnConnectionStateCommand(long domainId, long accountId, long zoneId, - Long vpcId, String vpcName, String connectionUuid, + Long vpcId, String vpcName, long connectionId, boolean enabled) { super(domainId, accountId, zoneId); this.vpcId = vpcId; this.vpcName = vpcName; - this.connectionUuid = connectionUuid; + this.connectionId = connectionId; this.enabled = enabled; } @@ -43,8 +43,8 @@ public String getVpcName() { return vpcName; } - public String getConnectionUuid() { - return connectionUuid; + public long getConnectionId() { + return connectionId; } public boolean isEnabled() { @@ -60,13 +60,12 @@ public boolean equals(Object o) { return false; } UpdateNsxVpnConnectionStateCommand that = (UpdateNsxVpnConnectionStateCommand) o; - return enabled == that.enabled && Objects.equals(vpcId, that.vpcId) - && Objects.equals(vpcName, that.vpcName) - && Objects.equals(connectionUuid, that.connectionUuid); + return connectionId == that.connectionId && enabled == that.enabled && Objects.equals(vpcId, that.vpcId) + && Objects.equals(vpcName, that.vpcName); } @Override public int hashCode() { - return Objects.hash(super.hashCode(), vpcId, vpcName, connectionUuid, enabled); + return Objects.hash(super.hashCode(), vpcId, vpcName, connectionId, enabled); } } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java index 606a394569bb..f5ec11f84113 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/resource/NsxResource.java @@ -28,7 +28,6 @@ import com.cloud.host.Host; import com.cloud.network.Network; import com.cloud.resource.ServerResource; -import com.cloud.utils.Pair; import com.cloud.utils.exception.CloudRuntimeException; import com.vmware.nsx.model.TransportZone; @@ -535,7 +534,7 @@ private NsxAnswer executeRequest(CreateNsxVpnGatewayCommand cmd) { tier1GatewayName, e.getMessage())); return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); } - if (vpnServiceExisted) { + if (vpnServiceExisted && !cmd.isReconcileExistingService()) { NsxAnswer answer = new NsxAnswer(cmd, new CloudRuntimeException(String.format( "An NSX VPN service already exists on tier-1 gateway %s; refusing to adopt or overwrite it", tier1GatewayName))); @@ -545,13 +544,15 @@ private NsxAnswer executeRequest(CreateNsxVpnGatewayCommand cmd) { try { nsxApiClient.createVpnService(tier1GatewayName, cmd.getLocalEndpointIp()); } catch (Exception e) { - boolean endpointMayBeInUse = false; - try { - nsxApiClient.deleteVpnService(tier1GatewayName); - } catch (Exception rollbackException) { - endpointMayBeInUse = true; - logger.warn("Failed to roll back the newly-created NSX VPN service on tier-1 gateway {} after creation failed: {}", - tier1GatewayName, rollbackException.getMessage()); + boolean endpointMayBeInUse = vpnServiceExisted; + if (!vpnServiceExisted) { + try { + nsxApiClient.deleteVpnService(tier1GatewayName); + } catch (Exception rollbackException) { + endpointMayBeInUse = true; + logger.warn("Failed to roll back the newly-created NSX VPN service on tier-1 gateway {} after creation failed: {}", + tier1GatewayName, rollbackException.getMessage()); + } } logger.error(String.format("Failed to create the NSX VPN service on tier-1 gateway %s for VPC %s: %s", tier1GatewayName, cmd.getVpcName(), e.getMessage())); @@ -591,40 +592,39 @@ private NsxAnswer executeRequest(CreateNsxVpnConnectionCommand cmd) { // The requested VTI /30 is derived from the connection id. Fail closed when another // session already owns its local address; silently choosing a different pair would make // the peer route advertised by CloudStack disagree with the pair installed in NSX. - Set inUseVtiIps = nsxApiClient.getRouteBasedVpnSessionLocalVtiIps(tier1GatewayName, cmd.getConnectionUuid()); + Set inUseVtiIps = nsxApiClient.getRouteBasedVpnSessionLocalVtiIps(tier1GatewayName, cmd.getConnectionId()); if (inUseVtiIps.contains(cmd.getVtiLocalIp())) { throw new CloudRuntimeException(String.format( "The deterministic VTI address %s for VPN connection %s is already in use on tier-1 gateway %s", - cmd.getVtiLocalIp(), cmd.getConnectionUuid(), tier1GatewayName)); + cmd.getVtiLocalIp(), cmd.getConnectionId(), tier1GatewayName)); } - Pair vtiAddresses = new Pair<>(cmd.getVtiLocalIp(), cmd.getVtiPeerIp()); - provisioningResult = nsxApiClient.createRouteBasedVpnSession(tier1GatewayName, cmd.getConnectionUuid(), cmd.getPeerAddress(), + provisioningResult = nsxApiClient.createRouteBasedVpnSession(tier1GatewayName, cmd.getConnectionId(), cmd.getPeerAddress(), cmd.getPsk(), cmd.getIkePolicy(), cmd.getEspPolicy(), cmd.getIkeLifetime(), cmd.getEspLifetime(), - cmd.isDpdEnabled(), cmd.getIkeVersion(), cmd.isPassive(), vtiAddresses.first(), cmd.getVtiPrefixLength()); - nsxApiClient.addVpnConnectionRoutes(tier1GatewayName, cmd.getConnectionUuid(), cmd.getPeerCidrs(), - vtiAddresses.second(), cmd.getVpcCidr()); + cmd.isDpdEnabled(), cmd.getIkeVersion(), cmd.isPassive(), cmd.getVtiLocalIp(), cmd.getVtiPrefixLength()); + nsxApiClient.addVpnConnectionRoutes(tier1GatewayName, cmd.getConnectionId(), cmd.getPeerCidrs(), + cmd.getVtiPeerIp(), cmd.getVpcCidr()); // Applied here as well so that VPN gateways created before the exemptions existed, or whose // tier-1 gained a source NAT rule afterwards, are corrected without recreating the gateway nsxApiClient.ensureVpnNatExemptions(tier1GatewayName, cmd.getLocalEndpointIp()); - nsxApiClient.updateVpnConnectionState(tier1GatewayName, cmd.getConnectionUuid(), true); + nsxApiClient.updateVpnConnectionState(tier1GatewayName, cmd.getConnectionId(), true); } catch (Exception e) { if (provisioningResult == NsxApiClient.VpnSessionProvisioningResult.CREATED) { try { - nsxApiClient.rollbackVpnConnection(tier1GatewayName, cmd.getConnectionUuid()); + nsxApiClient.rollbackVpnConnection(tier1GatewayName, cmd.getConnectionId()); } catch (Exception rollbackException) { logger.warn("Failed to roll back the partially created NSX VPN connection {}: {}", - cmd.getConnectionUuid(), rollbackException.getMessage()); + cmd.getConnectionId(), rollbackException.getMessage()); } } else if (provisioningResult == NsxApiClient.VpnSessionProvisioningResult.PREEXISTING) { try { - nsxApiClient.updateVpnConnectionState(tier1GatewayName, cmd.getConnectionUuid(), false); + nsxApiClient.updateVpnConnectionState(tier1GatewayName, cmd.getConnectionId(), false); } catch (Exception compensationException) { logger.warn("Failed to disable the pre-existing NSX VPN connection {} after provisioning failed: {}", - cmd.getConnectionUuid(), compensationException.getMessage()); + cmd.getConnectionId(), compensationException.getMessage()); } } logger.error(String.format("Failed to create the NSX VPN connection %s on tier-1 gateway %s for VPC %s: %s", - cmd.getConnectionUuid(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); + cmd.getConnectionId(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); } } @@ -637,10 +637,10 @@ private NsxAnswer executeRequest(DeleteNsxVpnConnectionCommand cmd) { Object lock = getVpnTier1Lock(tier1GatewayName); synchronized (lock) { try { - nsxApiClient.deleteVpnConnection(tier1GatewayName, cmd.getConnectionUuid()); + nsxApiClient.deleteVpnConnection(tier1GatewayName, cmd.getConnectionId()); } catch (Exception e) { logger.error(String.format("Failed to delete the NSX VPN connection %s on tier-1 gateway %s for VPC %s: %s", - cmd.getConnectionUuid(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); + cmd.getConnectionId(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); } } @@ -653,10 +653,10 @@ private NsxAnswer executeRequest(UpdateNsxVpnConnectionStateCommand cmd) { Object lock = getVpnTier1Lock(tier1GatewayName); synchronized (lock) { try { - nsxApiClient.updateVpnConnectionState(tier1GatewayName, cmd.getConnectionUuid(), cmd.isEnabled()); + nsxApiClient.updateVpnConnectionState(tier1GatewayName, cmd.getConnectionId(), cmd.isEnabled()); } catch (Exception e) { logger.error(String.format("Failed to update the state of the NSX VPN connection %s on tier-1 gateway %s for VPC %s: %s", - cmd.getConnectionUuid(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); + cmd.getConnectionId(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); } } @@ -672,10 +672,10 @@ private NsxAnswer executeRequest(GetNsxVpnSessionStatusCommand cmd) { cmd.getZoneId(), cmd.getVpcId(), true); String status; try { - status = nsxApiClient.getVpnSessionStatus(tier1GatewayName, cmd.getConnectionUuid()); + status = nsxApiClient.getVpnSessionStatus(tier1GatewayName, cmd.getConnectionId()); } catch (Exception e) { logger.error(String.format("Failed to get the status of the NSX VPN connection %s on tier-1 gateway %s for VPC %s: %s", - cmd.getConnectionUuid(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); + cmd.getConnectionId(), tier1GatewayName, cmd.getVpcName(), e.getMessage())); return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); } return new NsxAnswer(cmd, true, status); diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java index fa1f94813a9d..4784824d64ab 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java @@ -150,6 +150,7 @@ import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnNoSnatRuleNamePrefix; import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnServiceName; import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnSessionName; +import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnSessionNamePrefix; import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnStaticRouteName; import static org.apache.cloudstack.utils.NsxControllerUtils.getVpnStaticRouteNamePrefix; @@ -489,10 +490,13 @@ private Tier1 getTier1Gateway(String tier1GatewayId) { try { Tier1s tier1service = (Tier1s) nsxService.apply(Tier1s.class); return tier1service.get(tier1GatewayId); - } catch (Exception e) { + } catch (NotFound e) { logger.debug("NSX Tier-1 gateway with name: {} not found", tier1GatewayId); + return null; + } catch (RuntimeException e) { + throw new CloudRuntimeException(String.format("Failed to obtain NSX Tier-1 gateway %s", + tier1GatewayId), e); } - return null; } private Optional findTier0LocalServices(String tier0Gateway) { @@ -1528,13 +1532,13 @@ public void deleteVpnService(String tier1GatewayName) { restoreSourceNatRuleSequence(tier1GatewayName); } - public VpnSessionProvisioningResult createRouteBasedVpnSession(String tier1GatewayName, String connectionUuid, + public VpnSessionProvisioningResult createRouteBasedVpnSession(String tier1GatewayName, long connectionId, String peerAddress, String psk, String ikePolicy, String espPolicy, Long ikeLifetime, Long espLifetime, boolean dpdEnabled, String ikeVersion, boolean passive, String vtiLocalIp, int vtiPrefixLength) { - return retryWhileMarkedForDeletion(connectionUuid, () -> doCreateRouteBasedVpnSession(tier1GatewayName, - connectionUuid, peerAddress, psk, ikePolicy, espPolicy, ikeLifetime, espLifetime, dpdEnabled, + return retryWhileMarkedForDeletion(connectionId, () -> doCreateRouteBasedVpnSession(tier1GatewayName, + connectionId, peerAddress, psk, ikePolicy, espPolicy, ikeLifetime, espLifetime, dpdEnabled, ikeVersion, passive, vtiLocalIp, vtiPrefixLength)); } @@ -1543,7 +1547,7 @@ public VpnSessionProvisioningResult createRouteBasedVpnSession(String tier1Gatew * marked for deletion. All VPN objects use deterministic IDs and PATCH/upsert semantics, so a * retry of the complete idempotent operation is safe and preserves already-existing objects. */ - private T retryWhileMarkedForDeletion(String connectionUuid, Supplier operation) { + private T retryWhileMarkedForDeletion(long connectionId, Supplier operation) { CloudRuntimeException lastFailure = null; for (int attempt = 1; attempt <= VPN_MARKED_FOR_DELETION_RETRIES; attempt++) { try { @@ -1554,7 +1558,7 @@ private T retryWhileMarkedForDeletion(String connectionUuid, Supplier ope throw e; } logger.info("A VPN object for connection {} is still being purged by NSX, retrying in {}s (attempt {}/{})", - connectionUuid, VPN_MARKED_FOR_DELETION_RETRY_INTERVAL_SECS, attempt, VPN_MARKED_FOR_DELETION_RETRIES); + connectionId, VPN_MARKED_FOR_DELETION_RETRY_INTERVAL_SECS, attempt, VPN_MARKED_FOR_DELETION_RETRIES); try { Thread.sleep(VPN_MARKED_FOR_DELETION_RETRY_INTERVAL_SECS * 1000L); } catch (InterruptedException ie) { @@ -1570,17 +1574,17 @@ private boolean isMarkedForDeletionError(CloudRuntimeException e) { return e.getMessage() != null && e.getMessage().contains("marked for deletion"); } - private VpnSessionProvisioningResult doCreateRouteBasedVpnSession(String tier1GatewayName, String connectionUuid, + private VpnSessionProvisioningResult doCreateRouteBasedVpnSession(String tier1GatewayName, long connectionId, String peerAddress, String psk, String ikePolicy, String espPolicy, Long ikeLifetime, Long espLifetime, boolean dpdEnabled, String ikeVersion, boolean passive, String vtiLocalIp, int vtiPrefixLength) { String vpnServiceName = getVpnServiceName(tier1GatewayName); String localEndpointName = getVpnLocalEndpointName(vpnServiceName); - String sessionName = getVpnSessionName(connectionUuid); - String ikeProfileName = getVpnIkeProfileName(connectionUuid); - String espProfileName = getVpnEspProfileName(connectionUuid); - String dpdProfileName = getVpnDpdProfileName(connectionUuid); + String sessionName = getVpnSessionName(connectionId); + String ikeProfileName = getVpnIkeProfileName(connectionId); + String espProfileName = getVpnEspProfileName(connectionId); + String dpdProfileName = getVpnDpdProfileName(connectionId); try { IpsecVpnIkeProfiles ikeProfiles = (IpsecVpnIkeProfiles) nsxService.apply(IpsecVpnIkeProfiles.class); IpsecVpnTunnelProfiles espProfiles = (IpsecVpnTunnelProfiles) nsxService.apply(IpsecVpnTunnelProfiles.class); @@ -1590,7 +1594,7 @@ private VpnSessionProvisioningResult doCreateRouteBasedVpnSession(String tier1Ga if (sessionExisted) { // Disable the existing session before replacing any referenced profiles so a failed // reconciliation cannot leave an active tunnel using a partially updated policy. - updateVpnConnectionState(tier1GatewayName, connectionUuid, false); + updateVpnConnectionState(tier1GatewayName, connectionId, false); } boolean ikeProfileExisted = isVpnIkeProfilePresent(ikeProfiles, ikeProfileName); boolean espProfileExisted = isVpnTunnelProfilePresent(espProfiles, espProfileName); @@ -1667,7 +1671,7 @@ private VpnSessionProvisioningResult doCreateRouteBasedVpnSession(String tier1Ga vpnServiceName, sessionName); if (sessionRemoved) { deleteNewVpnProfilesAfterCreateFailure(ikeProfiles, espProfiles, dpdProfiles, - connectionUuid, ikeProfileExisted, espProfileExisted, dpdProfileExisted); + connectionId, ikeProfileExisted, espProfileExisted, dpdProfileExisted); } } throw e; @@ -1737,46 +1741,46 @@ private boolean deleteVpnSessionAfterCreateFailure(Sessions sessions, String tie private void deleteNewVpnProfilesAfterCreateFailure(IpsecVpnIkeProfiles ikeProfiles, IpsecVpnTunnelProfiles espProfiles, IpsecVpnDpdProfiles dpdProfiles, - String connectionUuid, + long connectionId, boolean ikeProfileExisted, boolean espProfileExisted, boolean dpdProfileExisted) { if (!ikeProfileExisted) { - deleteVpnProfileAfterCreateFailure(() -> ikeProfiles.delete(getVpnIkeProfileName(connectionUuid)), - "IKE", connectionUuid); + deleteVpnProfileAfterCreateFailure(() -> ikeProfiles.delete(getVpnIkeProfileName(connectionId)), + "IKE", connectionId); } if (!espProfileExisted) { - deleteVpnProfileAfterCreateFailure(() -> espProfiles.delete(getVpnEspProfileName(connectionUuid)), - "tunnel", connectionUuid); + deleteVpnProfileAfterCreateFailure(() -> espProfiles.delete(getVpnEspProfileName(connectionId)), + "tunnel", connectionId); } if (!dpdProfileExisted) { - deleteVpnProfileAfterCreateFailure(() -> dpdProfiles.delete(getVpnDpdProfileName(connectionUuid)), - "DPD", connectionUuid); + deleteVpnProfileAfterCreateFailure(() -> dpdProfiles.delete(getVpnDpdProfileName(connectionId)), + "DPD", connectionId); } } private void deleteVpnProfileAfterCreateFailure(Runnable deleteAction, String profileType, - String connectionUuid) { + long connectionId) { try { deleteAction.run(); } catch (NotFound e) { logger.debug("The partially created {} profile of VPN connection {} was absent during cleanup", - profileType, connectionUuid); + profileType, connectionId); } catch (RuntimeException e) { logger.warn("Failed to remove the partially created {} profile of VPN connection {}: {}", - profileType, connectionUuid, e.getMessage()); + profileType, connectionId, e.getMessage()); } } - public void addVpnConnectionRoutes(String tier1GatewayName, String connectionUuid, List peerCidrs, + public void addVpnConnectionRoutes(String tier1GatewayName, long connectionId, List peerCidrs, String vtiPeerIp, String vpcCidr) { - retryWhileMarkedForDeletion(connectionUuid, () -> { - doAddVpnConnectionRoutes(tier1GatewayName, connectionUuid, peerCidrs, vtiPeerIp, vpcCidr); + retryWhileMarkedForDeletion(connectionId, () -> { + doAddVpnConnectionRoutes(tier1GatewayName, connectionId, peerCidrs, vtiPeerIp, vpcCidr); return null; }); } - private void doAddVpnConnectionRoutes(String tier1GatewayName, String connectionUuid, List peerCidrs, + private void doAddVpnConnectionRoutes(String tier1GatewayName, long connectionId, List peerCidrs, String vtiPeerIp, String vpcCidr) { try { com.vmware.nsx_policy.infra.tier_1s.StaticRoutes staticRoutesService = @@ -1786,7 +1790,7 @@ private void doAddVpnConnectionRoutes(String tier1GatewayName, String connection Set desiredNoSnatRuleIds = new HashSet<>(); for (int i = 0; i < peerCidrs.size(); i++) { String peerCidr = peerCidrs.get(i); - String routeName = getVpnStaticRouteName(connectionUuid, i); + String routeName = getVpnStaticRouteName(connectionId, i); desiredRouteIds.add(routeName); com.vmware.nsx_policy.model.StaticRoutes staticRoute = new com.vmware.nsx_policy.model.StaticRoutes.Builder() .setId(routeName) @@ -1798,7 +1802,7 @@ private void doAddVpnConnectionRoutes(String tier1GatewayName, String connection // Route-based VPN does not bypass NAT: without a NO_SNAT rule the tier-1 match-any // SNAT would rewrite VPC-to-remote traffic before it enters the tunnel - String noSnatRuleName = getVpnNoSnatRuleName(connectionUuid, i); + String noSnatRuleName = getVpnNoSnatRuleName(connectionId, i); desiredNoSnatRuleIds.add(noSnatRuleName); PolicyNatRule noSnatRule = new PolicyNatRule.Builder() .setId(noSnatRuleName) @@ -1813,37 +1817,38 @@ private void doAddVpnConnectionRoutes(String tier1GatewayName, String connection } // Keep existing routes and exemptions in place until every desired object has been // accepted. This makes an idempotent retry non-destructive if an NSX PATCH fails. - deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid), + deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionId), desiredRouteIds); - deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid), + deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionId), desiredNoSnatRuleIds); } catch (Error error) { ApiError ae = error.getData()._convertTo(ApiError.class); String msg = String.format("Failed to add the routes for NSX IPSec VPN connection %s on tier-1 gateway %s, due to: %s", - connectionUuid, tier1GatewayName, ae.getErrorMessage()); + connectionId, tier1GatewayName, ae.getErrorMessage()); logger.error(msg); throw new CloudRuntimeException(msg); } } - public void deleteVpnConnection(String tier1GatewayName, String connectionUuid) { + public void deleteVpnConnection(String tier1GatewayName, long connectionId) { RuntimeException failure = null; + String connectionReference = Long.toString(connectionId); // Delete by prefix instead of recomputing names from the current peer CIDR list: the // customer gateway's CIDRs may have changed since the routes and NO_SNAT rules were created - failure = runVpnCleanupStep(failure, "static routes", connectionUuid, - () -> deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid))); - failure = runVpnCleanupStep(failure, "NO_SNAT rules", connectionUuid, - () -> deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid))); - failure = runVpnCleanupStep(failure, "session", connectionUuid, - () -> deleteVpnSession(tier1GatewayName, connectionUuid)); - failure = runVpnCleanupStep(failure, "profiles", connectionUuid, - () -> deleteVpnSessionProfiles(connectionUuid)); - throwVpnCleanupFailure(failure, connectionUuid); - } - - private void deleteVpnSession(String tier1GatewayName, String connectionUuid) { + failure = runVpnCleanupStep(failure, "static routes", connectionReference, + () -> deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionId))); + failure = runVpnCleanupStep(failure, "NO_SNAT rules", connectionReference, + () -> deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionId))); + failure = runVpnCleanupStep(failure, "session", connectionReference, + () -> deleteVpnSession(tier1GatewayName, connectionId)); + failure = runVpnCleanupStep(failure, "profiles", connectionReference, + () -> deleteVpnSessionProfiles(connectionId)); + throwVpnCleanupFailure(failure, connectionId); + } + + private void deleteVpnSession(String tier1GatewayName, long connectionId) { String vpnServiceName = getVpnServiceName(tier1GatewayName); - String sessionName = getVpnSessionName(connectionUuid); + String sessionName = getVpnSessionName(connectionId); try { Sessions sessions = (Sessions) nsxService.apply(Sessions.class); sessions.delete(tier1GatewayName, vpnServiceName, sessionName); @@ -1859,9 +1864,9 @@ private void deleteVpnSession(String tier1GatewayName, String connectionUuid) { } } - public void updateVpnConnectionState(String tier1GatewayName, String connectionUuid, boolean enabled) { + public void updateVpnConnectionState(String tier1GatewayName, long connectionId, boolean enabled) { String vpnServiceName = getVpnServiceName(tier1GatewayName); - String sessionName = getVpnSessionName(connectionUuid); + String sessionName = getVpnSessionName(connectionId); try { Sessions sessions = (Sessions) nsxService.apply(Sessions.class); Structure current = sessions.showsensitivedata(tier1GatewayName, vpnServiceName, sessionName); @@ -1904,8 +1909,8 @@ public void updateVpnConnectionState(String tier1GatewayName, String connectionU sessionName, tier1GatewayName, ae.getErrorMessage()), error); } if (!enabled) { - deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid)); - deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid)); + deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionId)); + deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionId)); } } @@ -1913,8 +1918,8 @@ public void updateVpnConnectionState(String tier1GatewayName, String connectionU * Removes every object created for a connection when route or NAT programming fails after the * session itself was created. This is also used by the permanent connection-delete path. */ - public void rollbackVpnConnection(String tier1GatewayName, String connectionUuid) { - deleteVpnConnection(tier1GatewayName, connectionUuid); + public void rollbackVpnConnection(String tier1GatewayName, long connectionId) { + deleteVpnConnection(tier1GatewayName, connectionId); } private void deleteVpnStaticRoutesByPrefix(String tier1GatewayName, String routeNamePrefix) { @@ -2045,63 +2050,70 @@ private void throwVpnPrefixCleanupFailure(RuntimeException failure, String resou } } - private void deleteVpnSessionProfiles(String connectionUuid) { + private void deleteVpnSessionProfiles(long connectionId) { + deleteVpnSessionProfiles(getVpnSessionName(connectionId)); + } + + private void deleteVpnSessionProfiles(String sessionName) { RuntimeException failure = null; - failure = runVpnCleanupStep(failure, "IKE profile", connectionUuid, - () -> deleteVpnIkeProfile(connectionUuid)); - failure = runVpnCleanupStep(failure, "tunnel profile", connectionUuid, - () -> deleteVpnTunnelProfile(connectionUuid)); - failure = runVpnCleanupStep(failure, "DPD profile", connectionUuid, - () -> deleteVpnDpdProfile(connectionUuid)); - throwVpnCleanupFailure(failure, connectionUuid); + failure = runVpnCleanupStep(failure, "IKE profile", sessionName, + () -> deleteVpnIkeProfile(sessionName)); + failure = runVpnCleanupStep(failure, "tunnel profile", sessionName, + () -> deleteVpnTunnelProfile(sessionName)); + failure = runVpnCleanupStep(failure, "DPD profile", sessionName, + () -> deleteVpnDpdProfile(sessionName)); + if (failure != null) { + throw new CloudRuntimeException(String.format( + "Failed to completely remove NSX VPN profiles for session %s", sessionName), failure); + } } - private void deleteVpnIkeProfile(String connectionUuid) { + private void deleteVpnIkeProfile(String sessionName) { try { IpsecVpnIkeProfiles ikeProfiles = (IpsecVpnIkeProfiles) nsxService.apply(IpsecVpnIkeProfiles.class); - ikeProfiles.delete(getVpnIkeProfileName(connectionUuid)); + ikeProfiles.delete(sessionName + "-ike"); } catch (NotFound e) { - logger.debug("The IKE profile of VPN connection {} no longer exists, skipping deletion", connectionUuid); + logger.debug("The IKE profile of VPN session {} no longer exists, skipping deletion", sessionName); } catch (Error error) { ApiError ae = error.getData()._convertTo(ApiError.class); - String msg = String.format("Failed to delete the IKE profile of VPN connection %s, due to: %s", - connectionUuid, ae.getErrorMessage()); + String msg = String.format("Failed to delete the IKE profile of VPN session %s, due to: %s", + sessionName, ae.getErrorMessage()); logger.error(msg); throw new CloudRuntimeException(msg); } } - private void deleteVpnTunnelProfile(String connectionUuid) { + private void deleteVpnTunnelProfile(String sessionName) { try { IpsecVpnTunnelProfiles espProfiles = (IpsecVpnTunnelProfiles) nsxService.apply(IpsecVpnTunnelProfiles.class); - espProfiles.delete(getVpnEspProfileName(connectionUuid)); + espProfiles.delete(sessionName + "-esp"); } catch (NotFound e) { - logger.debug("The tunnel profile of VPN connection {} no longer exists, skipping deletion", connectionUuid); + logger.debug("The tunnel profile of VPN session {} no longer exists, skipping deletion", sessionName); } catch (Error error) { ApiError ae = error.getData()._convertTo(ApiError.class); - String msg = String.format("Failed to delete the tunnel profile of VPN connection %s, due to: %s", - connectionUuid, ae.getErrorMessage()); + String msg = String.format("Failed to delete the tunnel profile of VPN session %s, due to: %s", + sessionName, ae.getErrorMessage()); logger.error(msg); throw new CloudRuntimeException(msg); } } - private void deleteVpnDpdProfile(String connectionUuid) { + private void deleteVpnDpdProfile(String sessionName) { try { IpsecVpnDpdProfiles dpdProfiles = (IpsecVpnDpdProfiles) nsxService.apply(IpsecVpnDpdProfiles.class); - dpdProfiles.delete(getVpnDpdProfileName(connectionUuid)); + dpdProfiles.delete(sessionName + "-dpd"); } catch (NotFound e) { - logger.debug("The DPD profile of VPN connection {} no longer exists, skipping deletion", connectionUuid); + logger.debug("The DPD profile of VPN session {} no longer exists, skipping deletion", sessionName); } catch (Error error) { ApiError ae = error.getData()._convertTo(ApiError.class); - String msg = String.format("Failed to delete the DPD profile of VPN connection %s, due to: %s", - connectionUuid, ae.getErrorMessage()); + String msg = String.format("Failed to delete the DPD profile of VPN session %s, due to: %s", + sessionName, ae.getErrorMessage()); logger.error(msg); throw new CloudRuntimeException(msg); } } - private RuntimeException runVpnCleanupStep(RuntimeException failure, String resource, String connectionUuid, + private RuntimeException runVpnCleanupStep(RuntimeException failure, String resource, String connectionReference, Runnable cleanup) { try { cleanup.run(); @@ -2111,12 +2123,12 @@ private RuntimeException runVpnCleanupStep(RuntimeException failure, String reso } failure.addSuppressed(e); logger.warn("Failed to remove NSX VPN {} for connection {} after an earlier cleanup failure: {}", - resource, connectionUuid, e.getMessage()); + resource, connectionReference, e.getMessage()); } return failure; } - private void throwVpnCleanupFailure(RuntimeException failure, String connectionUuid) { + private void throwVpnCleanupFailure(RuntimeException failure, long connectionId) { if (failure == null) { return; } @@ -2124,12 +2136,12 @@ private void throwVpnCleanupFailure(RuntimeException failure, String connectionU throw (CloudRuntimeException) failure; } throw new CloudRuntimeException(String.format( - "Failed to remove all NSX VPN resources for connection %s: %s", connectionUuid, failure.getMessage()), failure); + "Failed to remove all NSX VPN resources for connection %s: %s", connectionId, failure.getMessage()), failure); } - public String getVpnSessionStatus(String tier1GatewayName, String connectionUuid) { + public String getVpnSessionStatus(String tier1GatewayName, long connectionId) { String vpnServiceName = getVpnServiceName(tier1GatewayName); - String sessionName = getVpnSessionName(connectionUuid); + String sessionName = getVpnSessionName(connectionId); try { DetailedStatus detailedStatusService = (DetailedStatus) nsxService.apply(DetailedStatus.class); AggregateIPSecVpnSessionStatus aggregateStatus = detailedStatusService.get(tier1GatewayName, vpnServiceName, sessionName, null, null); @@ -2147,10 +2159,13 @@ public String getVpnSessionStatus(String tier1GatewayName, String connectionUuid if (statuses.contains(IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_DOWN)) { return IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_DOWN; } + if (statuses.contains(IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_DEGRADED)) { + return IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_DEGRADED; + } if (statuses.stream().allMatch(IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_UP::equals)) { return IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_UP; } - return statuses.get(0); + return VPN_SESSION_STATUS_UNKNOWN; } catch (NotFound e) { logger.debug("The VPN session {} no longer exists on tier-1 gateway {}", sessionName, tier1GatewayName); return VPN_SESSION_STATUS_NOT_FOUND; @@ -2168,8 +2183,8 @@ public String getVpnSessionStatus(String tier1GatewayName, String connectionUuid * locale-services deletion during gateway teardown */ private void removeTier1VpnResources(String tier1Id) { - deleteVpnStaticRoutesByPrefix(tier1Id, getVpnSessionName("")); - deleteVpnNoSnatRulesByPrefix(tier1Id, getVpnSessionName("")); + deleteVpnStaticRoutesByPrefix(tier1Id, getVpnSessionNamePrefix()); + deleteVpnNoSnatRulesByPrefix(tier1Id, getVpnSessionNamePrefix()); deleteVpnLocalEndpointNoSnatRule(tier1Id); try { IpsecVpnServices vpnServices = (IpsecVpnServices) nsxService.apply(IpsecVpnServices.class); @@ -2202,13 +2217,13 @@ private void removeTier1VpnResources(String tier1Id) { }) .fetchAll().getResults(); if (CollectionUtils.isNotEmpty(sessionResults)) { - String sessionNamePrefix = getVpnSessionName(""); + String sessionNamePrefix = getVpnSessionNamePrefix(); for (Structure result : sessionResults) { IPSecVpnSession session = result._convertTo(IPSecVpnSession.class); logger.debug("Removing VPN session {} from the VPN service {} of Tier 1 Gateway {}", session.getId(), service.getId(), tier1Id); - sessions.delete(tier1Id, service.getId(), session.getId()); + deleteTier1VpnSession(sessions, tier1Id, service.getId(), session.getId()); if (session.getId().startsWith(sessionNamePrefix)) { - deleteVpnSessionProfiles(session.getId().substring(sessionNamePrefix.length())); + deleteVpnSessionProfiles(session.getId()); } } } @@ -2224,11 +2239,11 @@ private void removeTier1VpnResources(String tier1Id) { if (CollectionUtils.isNotEmpty(localEndpointResults)) { for (IPSecVpnLocalEndpoint localEndpoint : localEndpointResults) { logger.debug("Removing VPN local endpoint {} from the VPN service {} of Tier 1 Gateway {}", localEndpoint.getId(), service.getId(), tier1Id); - localEndpoints.delete(tier1Id, service.getId(), localEndpoint.getId()); + deleteTier1VpnLocalEndpoint(localEndpoints, tier1Id, service.getId(), localEndpoint.getId()); } } logger.debug("Removing VPN service {} from Tier 1 Gateway {}", service.getId(), tier1Id); - vpnServices.delete(tier1Id, service.getId()); + deleteTier1VpnService(vpnServices, tier1Id, service.getId()); } } catch (NotFound e) { logger.debug("The VPN resources of the Tier 1 Gateway {} no longer exist, skipping deletion", tier1Id); @@ -2241,6 +2256,34 @@ private void removeTier1VpnResources(String tier1Id) { } } + private void deleteTier1VpnSession(Sessions sessions, String tier1Id, String serviceId, String sessionId) { + try { + sessions.delete(tier1Id, serviceId, sessionId); + } catch (NotFound e) { + logger.debug("The VPN session {} of service {} on Tier 1 Gateway {} no longer exists, skipping deletion", + sessionId, serviceId, tier1Id); + } + } + + private void deleteTier1VpnLocalEndpoint(LocalEndpoints localEndpoints, String tier1Id, String serviceId, + String localEndpointId) { + try { + localEndpoints.delete(tier1Id, serviceId, localEndpointId); + } catch (NotFound e) { + logger.debug("The VPN local endpoint {} of service {} on Tier 1 Gateway {} no longer exists, skipping deletion", + localEndpointId, serviceId, tier1Id); + } + } + + private void deleteTier1VpnService(IpsecVpnServices vpnServices, String tier1Id, String serviceId) { + try { + vpnServices.delete(tier1Id, serviceId); + } catch (NotFound e) { + logger.debug("The VPN service {} on Tier 1 Gateway {} no longer exists, skipping deletion", + serviceId, tier1Id); + } + } + private void deleteVpnLocalEndpointNoSnatRule(String tier1GatewayName) { String ruleName = getVpnLocalEndpointNoSnatRuleName(getVpnServiceName(tier1GatewayName)); try { @@ -2261,9 +2304,9 @@ private void deleteVpnLocalEndpointNoSnatRule(String tier1GatewayName) { * Lists the local VTI addresses of the route-based VPN sessions on a tier-1 gateway, excluding * the session of the given connection; used to fail closed on deterministic VTI collisions. */ - public Set getRouteBasedVpnSessionLocalVtiIps(String tier1GatewayName, String excludedConnectionUuid) { + public Set getRouteBasedVpnSessionLocalVtiIps(String tier1GatewayName, long excludedConnectionId) { String vpnServiceName = getVpnServiceName(tier1GatewayName); - String excludedSessionName = getVpnSessionName(excludedConnectionUuid); + String excludedSessionName = getVpnSessionName(excludedConnectionId); Set vtiIps = new HashSet<>(); try { Sessions sessions = (Sessions) nsxService.apply(Sessions.class); @@ -2277,12 +2320,13 @@ public Set getRouteBasedVpnSessionLocalVtiIps(String tier1GatewayName, S }) .fetchAll().getResults(); for (Structure result : sessionResults) { - IPSecVpnSession session = result._convertTo(IPSecVpnSession.class); - if (excludedSessionName.equals(session.getId()) - || !RouteBasedIPSecVpnSession.class.getSimpleName().equals(session.getResourceType())) { + if (!result._hasTypeNameOf(RouteBasedIPSecVpnSession.class)) { continue; } RouteBasedIPSecVpnSession routeBasedSession = result._convertTo(RouteBasedIPSecVpnSession.class); + if (excludedSessionName.equals(routeBasedSession.getId())) { + continue; + } if (CollectionUtils.isEmpty(routeBasedSession.getTunnelInterfaces())) { continue; } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java index ace525e669f6..2d0b70b9c3c7 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxElement.java @@ -50,6 +50,7 @@ import com.cloud.network.Site2SiteCustomerGateway; import com.cloud.network.Site2SiteVpnConnection; import com.cloud.network.Site2SiteVpnGateway; +import com.cloud.network.Site2SiteVpnTunnelInterface; import com.cloud.network.VirtualRouterProvider; import com.cloud.network.dao.FirewallRulesDao; import com.cloud.network.dao.IPAddressDao; @@ -992,17 +993,20 @@ protected boolean isVpnProvidedByNsx(Vpc vpc) { if (Objects.isNull(vpc)) { return false; } - if (vpcManager != null) { - return vpcManager.isProviderSupportServiceInVpc(vpc.getId(), Network.Service.Vpn, Network.Provider.Nsx); - } - return Objects.nonNull(vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId( - Network.Service.Vpn.getName(), Network.Provider.Nsx.getName(), vpc.getVpcOfferingId())); + return vpcManager.isProviderSupportServiceInVpc(vpc.getId(), Network.Service.Vpn, Network.Provider.Nsx); } protected boolean isVpnProvidedByNsx(Vpc vpc, Site2SiteVpnGateway gateway) { return vpc != null && ownsVpnGateway(gateway); } + private void validateVpnGatewayOwnedByNsx(Vpc vpc, Site2SiteVpnGateway gateway) { + if (!isVpnProvidedByNsx(vpc, gateway)) { + throw new CloudRuntimeException(String.format( + "NSX does not own Site-to-Site VPN gateway %s of VPC %s", gateway.getId(), vpc.getName())); + } + } + @Override public IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { if (!isVpnProvidedByNsx(vpc)) { @@ -1010,23 +1014,32 @@ public IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { } IPAddressVO ip; boolean autoAcquired = false; + boolean reconcileExistingService = false; + UserIpAddressDetailVO ownershipMarker = null; if (Objects.nonNull(requestedIp)) { ip = validateRequestedVpnGatewayIp(vpc, requestedIp); + ownershipMarker = userIpAddressDetailsDao.findDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL); + if (ownershipMarker != null) { + autoAcquired = isAutoAcquiredVpnGatewayIp(ownershipMarker, ip, vpc); + reconcileExistingService = true; + } } else { - ip = allocateVpnGatewayIp(vpc); - autoAcquired = true; + Pair retainedIp = findRetainedVpnGatewayIp(vpc); + if (retainedIp != null) { + ip = retainedIp.first(); + ownershipMarker = retainedIp.second(); + autoAcquired = isAutoAcquiredVpnGatewayIp(ownershipMarker, ip, vpc); + reconcileExistingService = true; + } else { + ip = allocateVpnGatewayIp(vpc); + autoAcquired = true; + } } boolean requestedIpOwnershipMarkerAdded = false; - if (!autoAcquired) { + if (!autoAcquired && ownershipMarker == null) { try { - // Preserve an earlier ownership marker: it may represent an ambiguous create that - // still requires provider-side cleanup and must not be downgraded on a retry. - UserIpAddressDetailVO existingOwnershipMarker = userIpAddressDetailsDao.findDetail( - ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL); - if (existingOwnershipMarker == null) { - userIpAddressDetailsDao.addDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL, "false", false); - requestedIpOwnershipMarkerAdded = true; - } + userIpAddressDetailsDao.addDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL, "false", false); + requestedIpOwnershipMarkerAdded = true; } catch (Exception e) { throw new CloudRuntimeException(String.format( "Failed to record NSX VPN ownership for requested IP %s of VPC %s", @@ -1035,7 +1048,7 @@ public IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { } boolean endpointMayBeInUse = true; try { - NsxVpnGatewayResult result = nsxService.createVpnGateway(vpc, ip.getAddress().addr()); + NsxVpnGatewayResult result = nsxService.createVpnGateway(vpc, ip.getAddress().addr(), reconcileExistingService); endpointMayBeInUse = result.isEndpointMayBeInUse(); if (!result.isSuccessful()) { throw new CloudRuntimeException(String.format("The NSX VPN gateway service for VPC %s was not created: the provider returned an unsuccessful answer", @@ -1066,6 +1079,40 @@ public IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { return ip; } + private Pair findRetainedVpnGatewayIp(Vpc vpc) { + Pair retainedIp = null; + List nonSourceNatIps = ipAddressDao.listByAssociatedVpc(vpc.getId(), false); + if (CollectionUtils.isNullOrEmpty(nonSourceNatIps)) { + return null; + } + for (IPAddressVO candidate : nonSourceNatIps) { + UserIpAddressDetailVO ownershipMarker = userIpAddressDetailsDao.findDetail( + candidate.getId(), NSX_VPN_GATEWAY_IP_DETAIL); + if (ownershipMarker == null) { + continue; + } + if (retainedIp != null) { + throw new CloudRuntimeException(String.format( + "VPC %s has more than one IP retained for an NSX VPN gateway; refusing ambiguous recovery", + vpc.getName())); + } + retainedIp = new Pair<>(validateRequestedVpnGatewayIp(vpc, candidate), ownershipMarker); + } + return retainedIp; + } + + private boolean isAutoAcquiredVpnGatewayIp(UserIpAddressDetailVO ownershipMarker, IPAddressVO ip, Vpc vpc) { + if ("true".equalsIgnoreCase(ownershipMarker.getValue())) { + return true; + } + if ("false".equalsIgnoreCase(ownershipMarker.getValue())) { + return false; + } + throw new CloudRuntimeException(String.format( + "IP %s retained for the NSX VPN gateway of VPC %s has invalid ownership state '%s'", + ip.getAddress(), vpc == null ? ip.getVpcId() : vpc.getName(), ownershipMarker.getValue())); + } + private IPAddressVO validateRequestedVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { IPAddressVO ip = ipAddressDao.findById(requestedIp.getId()); if (Objects.isNull(ip) || !Objects.equals(ip.getVpcId(), vpc.getId()) @@ -1164,7 +1211,8 @@ public void releaseVpnGatewayIp(Site2SiteVpnGateway gateway) { if (Objects.isNull(autoAcquiredDetail)) { return; } - if (Boolean.parseBoolean(autoAcquiredDetail.getValue())) { + boolean autoAcquired = isAutoAcquiredVpnGatewayIp(autoAcquiredDetail, ip, vpc); + if (autoAcquired) { logger.debug("Releasing the auto-acquired VPN gateway IP {} of VPC {}", ip.getAddress().addr(), vpc == null ? gateway.getVpcId() : vpc.getName()); releaseAutoAcquiredVpnGatewayIp(ip); @@ -1184,6 +1232,13 @@ public boolean ownsVpnGateway(Site2SiteVpnGateway gateway) { return userIpAddressDetailsDao.findDetail(gateway.getAddrId(), NSX_VPN_GATEWAY_IP_DETAIL) != null; } + @Override + public Site2SiteVpnTunnelInterface getSite2SiteVpnTunnelInterface(Site2SiteVpnConnection connection) { + Pair vtiAddresses = NsxHelper.getVpnVtiAddressPair(connection.getId()); + return new Site2SiteVpnTunnelInterface(vtiAddresses.first(), vtiAddresses.second(), + NsxHelper.VPN_VTI_PREFIX_LENGTH); + } + @Override public void validateSite2SiteVpnCustomerGateway(Site2SiteCustomerGateway customerGateway) { if (!NetUtils.isValidIp4(customerGateway.getGatewayIp())) { @@ -1209,9 +1264,7 @@ public boolean startSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUna "Cannot find the VPC %s of the VPN gateway of Site-to-Site VPN connection %s", vpnGateway.getVpcId(), conn.getUuid())); } - if (!isVpnProvidedByNsx(vpc, vpnGateway)) { - return true; - } + validateVpnGatewayOwnedByNsx(vpc, vpnGateway); Site2SiteCustomerGatewayVO customerGateway = customerGatewayDao.findById(conn.getCustomerGatewayId()); if (Objects.isNull(customerGateway)) { throw new CloudRuntimeException(String.format( @@ -1233,7 +1286,7 @@ public boolean startSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUna List peerCidrs = Arrays.stream(customerGateway.getGuestCidrList().split(",")) .map(String::trim) .collect(Collectors.toList()); - return nsxService.createVpnConnection(vpc, conn.getUuid(), customerGateway.getGatewayIp(), + return nsxService.createVpnConnection(vpc, conn.getId(), customerGateway.getGatewayIp(), customerGateway.getIpsecPsk(), customerGateway.getIkePolicy(), customerGateway.getEspPolicy(), customerGateway.getIkeLifetime(), customerGateway.getEspLifetime(), BooleanUtils.isTrue(customerGateway.getDpd()), customerGateway.getIkeVersion(), conn.isPassive(), @@ -1254,10 +1307,8 @@ public boolean stopSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUnav "Cannot find the VPC %s of the VPN gateway of Site-to-Site VPN connection %s", vpnGateway.getVpcId(), conn.getUuid())); } - if (!isVpnProvidedByNsx(vpc, vpnGateway)) { - return true; - } - return nsxService.updateVpnConnectionState(vpc, conn.getUuid(), false); + validateVpnGatewayOwnedByNsx(vpc, vpnGateway); + return nsxService.updateVpnConnectionState(vpc, conn.getId(), false); } @Override @@ -1271,6 +1322,7 @@ public boolean deleteSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUn if (vpc == null) { return true; } - return nsxService.deleteVpnConnection(vpc, conn.getUuid()); + validateVpnGatewayOwnedByNsx(vpc, vpnGateway); + return nsxService.deleteVpnConnection(vpc, conn.getId()); } } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java index a180525f7ceb..4b5f803d3568 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java @@ -74,6 +74,8 @@ import com.cloud.network.vpc.dao.VpcDao; import com.cloud.utils.component.ManagerBase; import com.cloud.utils.concurrency.NamedThreadFactory; +import com.cloud.utils.db.DB; +import com.cloud.utils.db.GlobalLock; import com.cloud.utils.exception.CloudRuntimeException; public class NsxServiceImpl extends ManagerBase implements NsxService, Configurable { @@ -90,6 +92,8 @@ public class NsxServiceImpl extends ManagerBase implements NsxService, Configura protected static final int VPN_STATUS_POLL_FAILURE_THRESHOLD = 3; protected static final int VPN_STATUS_POLL_MIN_INTERVAL = 10; protected static final int VPN_STATUS_POLL_DEFAULT_INTERVAL = 60; + protected static final String VPN_STATUS_POLL_LOCK_NAME = "nsx.vpn.status.poll"; + protected static final int VPN_STATUS_POLL_LOCK_TIMEOUT = 1; private static final List VPN_POLLED_STATES = List.of( Site2SiteVpnConnection.State.Pending, Site2SiteVpnConnection.State.Connecting, Site2SiteVpnConnection.State.Connected, @@ -294,9 +298,16 @@ public boolean deleteFirewallRules(Network network, List netRule return result.getResult(); } + @Override public NsxVpnGatewayResult createVpnGateway(Vpc vpc, String localEndpointIp) { + return createVpnGateway(vpc, localEndpointIp, false); + } + + @Override + public NsxVpnGatewayResult createVpnGateway(Vpc vpc, String localEndpointIp, boolean reconcileExistingService) { CreateNsxVpnGatewayCommand createNsxVpnGatewayCommand = new CreateNsxVpnGatewayCommand(vpc.getDomainId(), - vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), localEndpointIp); + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), localEndpointIp, + reconcileExistingService); NsxAnswer result = nsxControllerUtils.sendNsxCommandForResult(createNsxVpnGatewayCommand, vpc.getZoneId()); return new NsxVpnGatewayResult(result.getResult(), result.isEndpointMayBeInUse()); } @@ -308,69 +319,82 @@ public boolean deleteVpnGateway(Vpc vpc) { return result.getResult(); } - public boolean createVpnConnection(Vpc vpc, String connectionUuid, String peerAddress, String psk, + public boolean createVpnConnection(Vpc vpc, long connectionId, String peerAddress, String psk, String ikePolicy, String espPolicy, Long ikeLifetime, Long espLifetime, boolean dpdEnabled, String ikeVersion, boolean passive, List peerCidrs, String vtiLocalIp, String vtiPeerIp, int vtiPrefixLength, String localEndpointIp) { CreateNsxVpnConnectionCommand createNsxVpnConnectionCommand = new CreateNsxVpnConnectionCommand(vpc.getDomainId(), - vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionUuid, peerAddress, psk, + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionId, peerAddress, psk, ikePolicy, espPolicy, ikeLifetime, espLifetime, dpdEnabled, ikeVersion, passive, peerCidrs, vtiLocalIp, vtiPeerIp, vtiPrefixLength, vpc.getCidr(), localEndpointIp); NsxAnswer result = nsxControllerUtils.sendNsxCommand(createNsxVpnConnectionCommand, vpc.getZoneId()); return result.getResult(); } - public boolean deleteVpnConnection(Vpc vpc, String connectionUuid) { + public boolean deleteVpnConnection(Vpc vpc, long connectionId) { DeleteNsxVpnConnectionCommand deleteNsxVpnConnectionCommand = new DeleteNsxVpnConnectionCommand(vpc.getDomainId(), - vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionUuid); + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionId); NsxAnswer result = nsxControllerUtils.sendNsxCommand(deleteNsxVpnConnectionCommand, vpc.getZoneId()); return result.getResult(); } - public boolean updateVpnConnectionState(Vpc vpc, String connectionUuid, boolean enabled) { + public boolean updateVpnConnectionState(Vpc vpc, long connectionId, boolean enabled) { UpdateNsxVpnConnectionStateCommand command = new UpdateNsxVpnConnectionStateCommand(vpc.getDomainId(), - vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionUuid, enabled); + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionId, enabled); NsxAnswer result = nsxControllerUtils.sendNsxCommand(command, vpc.getZoneId()); return result.getResult(); } - public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + public String getVpnConnectionStatus(Vpc vpc, long connectionId) { GetNsxVpnSessionStatusCommand getNsxVpnSessionStatusCommand = new GetNsxVpnSessionStatusCommand(vpc.getDomainId(), - vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionUuid); + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), connectionId); NsxAnswer result = nsxControllerUtils.sendNsxCommand(getNsxVpnSessionStatusCommand, vpc.getZoneId()); return result.getDetails(); } /** - * Every management server runs this poller over VPN connections whose gateway has persisted - * NSX ownership; duplicate polling in a multi-server setup is tolerated, as state transitions - * are serialized by the row lock in transitionVpnConnectionState + * The global lock makes the scan a cluster-wide singleton. The connection row lock in + * transitionVpnConnectionState separately protects each state transition from a concurrent + * user operation that starts, stops, resets, or deletes the connection. */ protected class VpnStatusPollTask extends ManagedContextRunnable { @Override protected void runInContext() { + GlobalLock scanLock = GlobalLock.getInternLock(VPN_STATUS_POLL_LOCK_NAME); try { - Set polledConnectionIds = new HashSet<>(); - List connections = site2SiteVpnConnectionDao.listByStates( - VPN_POLLED_STATES.toArray(new Site2SiteVpnConnection.State[0])); - for (Site2SiteVpnConnectionVO connection : connections) { - Site2SiteVpnGatewayVO vpnGateway = site2SiteVpnGatewayDao.findById(connection.getVpnGatewayId()); - if (vpnGateway == null) { - continue; - } - VpcVO vpc = vpcDao.findById(vpnGateway.getVpcId()); - if (vpc == null || !isVpnProvidedByNsx(vpc, vpnGateway)) { - continue; + if (scanLock.lock(VPN_STATUS_POLL_LOCK_TIMEOUT)) { + try { + pollVpnConnectionStatuses(); + } finally { + scanLock.unlock(); } - polledConnectionIds.add(connection.getId()); - pollVpnConnectionStatus(connection, vpc); } - // Drop the failure counters of connections that were deleted or left the polled states - vpnStatusPollFailures.keySet().retainAll(polledConnectionIds); } catch (Exception e) { logger.warn("Failed to poll the status of the NSX Site-to-Site VPN connections: {}", e.getMessage(), e); + } finally { + scanLock.releaseRef(); + } + } + } + + private void pollVpnConnectionStatuses() { + Set polledConnectionIds = new HashSet<>(); + List connections = site2SiteVpnConnectionDao.listByStates( + VPN_POLLED_STATES.toArray(new Site2SiteVpnConnection.State[0])); + for (Site2SiteVpnConnectionVO connection : connections) { + Site2SiteVpnGatewayVO vpnGateway = site2SiteVpnGatewayDao.findById(connection.getVpnGatewayId()); + if (vpnGateway == null) { + continue; + } + VpcVO vpc = vpcDao.findById(vpnGateway.getVpcId()); + if (vpc == null || !isVpnProvidedByNsx(vpc, vpnGateway)) { + continue; } + polledConnectionIds.add(connection.getId()); + pollVpnConnectionStatus(connection, vpc); } + // Drop the failure counters of connections that were deleted or left the polled states. + vpnStatusPollFailures.keySet().retainAll(polledConnectionIds); } private boolean isVpnProvidedByNsx(Vpc vpc, Site2SiteVpnGatewayVO vpnGateway) { @@ -384,7 +408,7 @@ protected void pollVpnConnectionStatus(Site2SiteVpnConnectionVO connection, VpcV Site2SiteVpnConnection.State observedState = connection.getState(); String status; try { - status = getVpnConnectionStatus(vpc, connection.getUuid()); + status = getVpnConnectionStatus(vpc, connection.getId()); vpnStatusPollFailures.remove(connection.getId()); } catch (Exception e) { int failures = vpnStatusPollFailures.merge(connection.getId(), 1, Integer::sum); @@ -428,6 +452,7 @@ protected void pollVpnConnectionStatus(Site2SiteVpnConnectionVO connection, VpcV transitionVpnConnectionState(connection, vpc, observedState, newState); } + @DB protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, Site2SiteVpnConnection.State observedState, Site2SiteVpnConnection.State newState) { diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxControllerUtils.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxControllerUtils.java index 278ac1fa4f98..3d07830e01aa 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxControllerUtils.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/utils/NsxControllerUtils.java @@ -53,7 +53,7 @@ public static String getNsxDistributedFirewallPolicyRuleId(String segmentName, l public NsxAnswer sendNsxCommand(NsxCommand cmd, long zoneId) throws IllegalArgumentException { NsxAnswer answer = sendNsxCommandForResult(cmd, zoneId); if (!answer.getResult()) { - logger.error("NSX API Command failed"); + logger.error("NSX command {} failed in zone {}", cmd.getClass().getSimpleName(), zoneId); throw new InvalidParameterValueException("Failed API call to NSX controller"); } return answer; @@ -68,7 +68,13 @@ public NsxAnswer sendNsxCommandForResult(NsxCommand cmd, long zoneId) throws Ill Answer answer = agentMgr.easySend(nsxProviderVO.getHostId(), cmd); if (answer == null) { - logger.error("NSX API Command failed"); + logger.error("NSX command {} returned no answer in zone {}", cmd.getClass().getSimpleName(), zoneId); + throw new InvalidParameterValueException("Failed API call to NSX controller"); + } + + if (!(answer instanceof NsxAnswer)) { + logger.error("NSX command {} returned unexpected answer type {} in zone {}", + cmd.getClass().getSimpleName(), answer.getClass().getName(), zoneId); throw new InvalidParameterValueException("Failed API call to NSX controller"); } @@ -159,36 +165,40 @@ public static String getVpnLocalEndpointNoSnatRuleName(String vpnServiceName) { return vpnServiceName + "-le-nosnat"; } - public static String getVpnSessionName(String connectionUuid) { - return "cs-conn-" + connectionUuid; + public static String getVpnSessionNamePrefix() { + return "cs-conn-"; + } + + public static String getVpnSessionName(long connectionId) { + return getVpnSessionNamePrefix() + connectionId; } - public static String getVpnIkeProfileName(String connectionUuid) { - return getVpnSessionName(connectionUuid) + "-ike"; + public static String getVpnIkeProfileName(long connectionId) { + return getVpnSessionName(connectionId) + "-ike"; } - public static String getVpnEspProfileName(String connectionUuid) { - return getVpnSessionName(connectionUuid) + "-esp"; + public static String getVpnEspProfileName(long connectionId) { + return getVpnSessionName(connectionId) + "-esp"; } - public static String getVpnDpdProfileName(String connectionUuid) { - return getVpnSessionName(connectionUuid) + "-dpd"; + public static String getVpnDpdProfileName(long connectionId) { + return getVpnSessionName(connectionId) + "-dpd"; } - public static String getVpnStaticRouteNamePrefix(String connectionUuid) { - return getVpnSessionName(connectionUuid) + "-route"; + public static String getVpnStaticRouteNamePrefix(long connectionId) { + return getVpnSessionName(connectionId) + "-route"; } - public static String getVpnStaticRouteName(String connectionUuid, int peerCidrIndex) { - return getVpnStaticRouteNamePrefix(connectionUuid) + peerCidrIndex; + public static String getVpnStaticRouteName(long connectionId, int peerCidrIndex) { + return getVpnStaticRouteNamePrefix(connectionId) + peerCidrIndex; } - public static String getVpnNoSnatRuleNamePrefix(String connectionUuid) { - return getVpnSessionName(connectionUuid) + "-nosnat"; + public static String getVpnNoSnatRuleNamePrefix(long connectionId) { + return getVpnSessionName(connectionId) + "-nosnat"; } - public static String getVpnNoSnatRuleName(String connectionUuid, int peerCidrIndex) { - return getVpnNoSnatRuleNamePrefix(connectionUuid) + peerCidrIndex; + public static String getVpnNoSnatRuleName(long connectionId, int peerCidrIndex) { + return getVpnNoSnatRuleNamePrefix(connectionId) + peerCidrIndex; } public static String getLoadBalancerAlgorithm(String algorithm) { diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java index 27790354f19a..eed6eb205304 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java @@ -340,6 +340,19 @@ public void testCreateNsxVpnGatewayRefusesToAdoptExistingService() { verify(nsxApi, never()).deleteVpnService("D1-A2-Z1-V3"); } + @Test + public void testCreateNsxVpnGatewayReconcilesPreviouslyOwnedExistingService() { + CreateNsxVpnGatewayCommand command = new CreateNsxVpnGatewayCommand(domainId, accountId, zoneId, + 3L, "VPC01", "203.0.113.20", true); + when(nsxApi.isVpnServicePresent("D1-A2-Z1-V3")).thenReturn(true); + + NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); + + assertTrue(answer.getResult()); + verify(nsxApi).createVpnService("D1-A2-Z1-V3", "203.0.113.20"); + verify(nsxApi, never()).deleteVpnService("D1-A2-Z1-V3"); + } + @Test public void testCreateNsxVpnGatewayReportsPossibleResourceWhenRollbackFails() { CreateNsxVpnGatewayCommand command = new CreateNsxVpnGatewayCommand(domainId, accountId, zoneId, @@ -356,52 +369,52 @@ public void testCreateNsxVpnGatewayReportsPossibleResourceWhenRollbackFails() { @Test public void testCreateNsxVpnConnectionRollsBackNewSessionAfterRouteFailure() { CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, - 3L, "VPC01", "connection-uuid", "203.0.113.10", "psk", "aes256-sha256;modp2048", + 3L, "VPC01", 5L, "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, "10.1.0.0/16", "203.0.113.20"); - when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyString())).thenReturn(Set.of()); - when(nsxApi.createRouteBasedVpnSession(anyString(), anyString(), anyString(), anyString(), anyString(), + when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyLong())).thenReturn(Set.of()); + when(nsxApi.createRouteBasedVpnSession(anyString(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyString(), anyInt())) .thenReturn(NsxApiClient.VpnSessionProvisioningResult.CREATED); - doThrow(new CloudRuntimeException("ERROR")).when(nsxApi).addVpnConnectionRoutes(anyString(), anyString(), anyList(), anyString(), anyString()); + doThrow(new CloudRuntimeException("ERROR")).when(nsxApi).addVpnConnectionRoutes(anyString(), anyLong(), anyList(), anyString(), anyString()); NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); assertFalse(answer.getResult()); - verify(nsxApi).rollbackVpnConnection("D1-A2-Z1-V3", "connection-uuid"); + verify(nsxApi).rollbackVpnConnection("D1-A2-Z1-V3", 5L); } @Test public void testCreateNsxVpnConnectionDisablesPreexistingSessionAfterRouteFailure() { CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, - 3L, "VPC01", "connection-uuid", "203.0.113.10", "psk", "aes256-sha256;modp2048", + 3L, "VPC01", 5L, "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, "10.1.0.0/16", "203.0.113.20"); - when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyString())).thenReturn(Set.of()); - when(nsxApi.createRouteBasedVpnSession(anyString(), anyString(), anyString(), anyString(), anyString(), + when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyLong())).thenReturn(Set.of()); + when(nsxApi.createRouteBasedVpnSession(anyString(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyString(), anyInt())) .thenReturn(NsxApiClient.VpnSessionProvisioningResult.PREEXISTING); - doThrow(new CloudRuntimeException("ERROR")).when(nsxApi).addVpnConnectionRoutes(anyString(), anyString(), + doThrow(new CloudRuntimeException("ERROR")).when(nsxApi).addVpnConnectionRoutes(anyString(), anyLong(), anyList(), anyString(), anyString()); NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); assertFalse(answer.getResult()); - verify(nsxApi, never()).rollbackVpnConnection(anyString(), anyString()); - verify(nsxApi).updateVpnConnectionState("D1-A2-Z1-V3", "connection-uuid", false); + verify(nsxApi, never()).rollbackVpnConnection(anyString(), anyLong()); + verify(nsxApi).updateVpnConnectionState("D1-A2-Z1-V3", 5L, false); } @Test public void testCreateNsxVpnConnectionEnablesSessionAfterRoutesAndNatExemptions() { CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, - 3L, "VPC01", "connection-uuid", "203.0.113.10", "psk", "aes256-sha256;modp2048", + 3L, "VPC01", 5L, "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, "10.1.0.0/16", "203.0.113.20"); - when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyString())).thenReturn(Set.of()); - when(nsxApi.createRouteBasedVpnSession(anyString(), anyString(), anyString(), anyString(), anyString(), + when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyLong())).thenReturn(Set.of()); + when(nsxApi.createRouteBasedVpnSession(anyString(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyString(), anyInt())) .thenReturn(NsxApiClient.VpnSessionProvisioningResult.CREATED); @@ -409,27 +422,27 @@ public void testCreateNsxVpnConnectionEnablesSessionAfterRoutesAndNatExemptions( assertTrue(answer.getResult()); InOrder inOrder = Mockito.inOrder(nsxApi); - inOrder.verify(nsxApi).createRouteBasedVpnSession(anyString(), anyString(), anyString(), anyString(), anyString(), + inOrder.verify(nsxApi).createRouteBasedVpnSession(anyString(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyString(), anyInt()); - inOrder.verify(nsxApi).addVpnConnectionRoutes("D1-A2-Z1-V3", "connection-uuid", + inOrder.verify(nsxApi).addVpnConnectionRoutes("D1-A2-Z1-V3", 5L, List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16"); inOrder.verify(nsxApi).ensureVpnNatExemptions("D1-A2-Z1-V3", "203.0.113.20"); - inOrder.verify(nsxApi).updateVpnConnectionState("D1-A2-Z1-V3", "connection-uuid", true); + inOrder.verify(nsxApi).updateVpnConnectionState("D1-A2-Z1-V3", 5L, true); } @Test public void testCreateNsxVpnConnectionRejectsVtiCollisionBeforeCreate() { CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, - 3L, "VPC01", "connection-uuid", "203.0.113.10", "psk", "aes256-sha256;modp2048", + 3L, "VPC01", 5L, "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, "10.1.0.0/16", "203.0.113.20"); - when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyString())).thenReturn(Set.of("169.254.64.21")); + when(nsxApi.getRouteBasedVpnSessionLocalVtiIps(anyString(), anyLong())).thenReturn(Set.of("169.254.64.21")); NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); assertFalse(answer.getResult()); - verify(nsxApi, Mockito.never()).createRouteBasedVpnSession(anyString(), anyString(), anyString(), anyString(), + verify(nsxApi, Mockito.never()).createRouteBasedVpnSession(anyString(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyString(), anyInt()); } @@ -437,12 +450,12 @@ public void testCreateNsxVpnConnectionRejectsVtiCollisionBeforeCreate() { public void testNsxVpnLifecycleCommandsDispatch() { DeleteNsxVpnGatewayCommand deleteGateway = new DeleteNsxVpnGatewayCommand(domainId, accountId, zoneId, 3L, "VPC01"); DeleteNsxVpnConnectionCommand deleteConnection = new DeleteNsxVpnConnectionCommand(domainId, accountId, zoneId, - 3L, "VPC01", "connection-uuid"); + 3L, "VPC01", 5L); UpdateNsxVpnConnectionStateCommand update = new UpdateNsxVpnConnectionStateCommand(domainId, accountId, zoneId, - 3L, "VPC01", "connection-uuid", false); + 3L, "VPC01", 5L, false); GetNsxVpnSessionStatusCommand status = new GetNsxVpnSessionStatusCommand(domainId, accountId, zoneId, - 3L, "VPC01", "connection-uuid"); - when(nsxApi.getVpnSessionStatus("D1-A2-Z1-V3", "connection-uuid")).thenReturn("UP"); + 3L, "VPC01", 5L); + when(nsxApi.getVpnSessionStatus("D1-A2-Z1-V3", 5L)).thenReturn("UP"); assertTrue(((NsxAnswer) nsxResource.executeRequest(deleteGateway)).getResult()); assertTrue(((NsxAnswer) nsxResource.executeRequest(deleteConnection)).getResult()); @@ -451,14 +464,14 @@ public void testNsxVpnLifecycleCommandsDispatch() { assertTrue(statusAnswer.getResult()); assertTrue(statusAnswer.getDetails().contains("UP")); verify(nsxApi).deleteVpnService("D1-A2-Z1-V3"); - verify(nsxApi).deleteVpnConnection("D1-A2-Z1-V3", "connection-uuid"); - verify(nsxApi).updateVpnConnectionState("D1-A2-Z1-V3", "connection-uuid", false); + verify(nsxApi).deleteVpnConnection("D1-A2-Z1-V3", 5L); + verify(nsxApi).updateVpnConnectionState("D1-A2-Z1-V3", 5L, false); } @Test public void testNsxVpnConnectionCommandRoundTripsThroughCloudStackWireAdaptor() { CreateNsxVpnConnectionCommand command = new CreateNsxVpnConnectionCommand(domainId, accountId, zoneId, - 3L, "VPC01", "connection-uuid", "203.0.113.10", "secret-psk", "aes256-sha256;modp2048", + 3L, "VPC01", 5L, "203.0.113.10", "secret-psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", true, List.of("192.168.100.0/24", "192.168.101.0/24"), "169.254.64.21", "169.254.64.22", 30, "10.1.0.0/16", "203.0.113.20"); diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java index 93742fc9818e..564e9e09f535 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java @@ -35,13 +35,21 @@ import com.vmware.nsx_policy.infra.tier_1s.LocaleServices; import com.vmware.nsx_policy.infra.tier_1s.StaticRoutes; import com.vmware.nsx_policy.infra.tier_1s.nat.NatRules; +import com.vmware.nsx_policy.infra.tier_1s.ipsec_vpn_services.LocalEndpoints; import com.vmware.nsx_policy.infra.domains.Groups; import com.vmware.nsx_policy.infra.tier_1s.ipsec_vpn_services.Sessions; +import com.vmware.nsx_policy.infra.tier_1s.ipsec_vpn_services.sessions.DetailedStatus; +import com.vmware.nsx_policy.model.AggregateIPSecVpnSessionStatus; import com.vmware.nsx_policy.model.ApiError; import com.vmware.nsx_policy.model.Group; import com.vmware.nsx_policy.model.IPSecVpnDpdProfile; import com.vmware.nsx_policy.model.IPSecVpnIkeProfile; +import com.vmware.nsx_policy.model.IPSecVpnLocalEndpoint; +import com.vmware.nsx_policy.model.IPSecVpnLocalEndpointListResult; import com.vmware.nsx_policy.model.IPSecVpnSession; +import com.vmware.nsx_policy.model.IPSecVpnSessionListResult; +import com.vmware.nsx_policy.model.IPSecVpnSessionStatusNsxt; +import com.vmware.nsx_policy.model.IPSecVpnService; import com.vmware.nsx_policy.model.IPSecVpnServiceListResult; import com.vmware.nsx_policy.model.IPSecVpnTunnelInterface; import com.vmware.nsx_policy.model.IPSecVpnTunnelProfile; @@ -77,6 +85,7 @@ import org.mockito.InOrder; import java.util.List; +import java.util.Set; import java.util.function.Function; import static org.junit.Assert.assertThrows; @@ -97,6 +106,8 @@ public class NsxApiClientTest { private static final String TIER_1_GATEWAY_NAME = "t1"; + private static final long CONNECTION_ID = 5L; + private static final long EXISTING_CONNECTION_ID = 6L; @Mock private Function, Service> nsxService; @@ -490,13 +501,13 @@ public void testDeleteTier1GatewayRemovesVpnResourcesBeforeLocaleServices() { when(staticRoutes.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) .thenReturn(staticRoutesResult); - when(vpnStaticRoute.getId()).thenReturn("cs-conn-connection-uuid-route0"); + when(vpnStaticRoute.getId()).thenReturn("cs-conn-5-route0"); when(operatorStaticRoute.getId()).thenReturn("operator-route"); when(staticRoutesResult.getResults()).thenReturn(List.of(vpnStaticRoute, operatorStaticRoute)); when(natRules.list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) .thenReturn(natRulesResult, remainingNatRulesResult); - when(vpnNoSnatRule.getId()).thenReturn("cs-conn-connection-uuid-nosnat0"); + when(vpnNoSnatRule.getId()).thenReturn("cs-conn-5-nosnat0"); when(operatorNatRule.getId()).thenReturn("operator-nat-rule"); when(natRulesResult.getResults()).thenReturn(List.of(vpnNoSnatRule, operatorNatRule)); when(remainingNatRulesResult.getResults()).thenReturn(List.of(operatorNatRule)); @@ -510,10 +521,10 @@ public void testDeleteTier1GatewayRemovesVpnResourcesBeforeLocaleServices() { InOrder inOrder = Mockito.inOrder(staticRoutes, natRules, vpnServices, localeServices, tier1s); inOrder.verify(staticRoutes).list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); - inOrder.verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route0"); + inOrder.verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-5-route0"); inOrder.verify(natRules).list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); - inOrder.verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat0"); + inOrder.verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-5-nosnat0"); inOrder.verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "t1-vpn-le-nosnat"); inOrder.verify(vpnServices).list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), eq(false), nullable(String.class)); @@ -525,6 +536,100 @@ public void testDeleteTier1GatewayRemovesVpnResourcesBeforeLocaleServices() { verify(staticRoutes, never()).delete(TIER_1_GATEWAY_NAME, "operator-route"); } + @Test + public void testDeleteVpnServiceTreatsMissingTier1AsAlreadyDeleted() { + Tier1s tier1s = Mockito.mock(Tier1s.class); + when(nsxService.apply(Tier1s.class)).thenReturn(tier1s); + when(tier1s.get(TIER_1_GATEWAY_NAME)).thenThrow(new NotFound(null, null)); + + client.deleteVpnService(TIER_1_GATEWAY_NAME); + + verify(tier1s).get(TIER_1_GATEWAY_NAME); + verify(nsxService, never()).apply(StaticRoutes.class); + verify(nsxService, never()).apply(NatRules.class); + verify(nsxService, never()).apply(IpsecVpnServices.class); + } + + @Test + public void testDeleteVpnServicePropagatesTier1TransportFailureBeforeCleanup() { + Tier1s tier1s = Mockito.mock(Tier1s.class); + RuntimeException transportFailure = new RuntimeException("transport failed"); + when(nsxService.apply(Tier1s.class)).thenReturn(tier1s); + when(tier1s.get(TIER_1_GATEWAY_NAME)).thenThrow(transportFailure); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> client.deleteVpnService(TIER_1_GATEWAY_NAME)); + + assertTrue(exception.getMessage().contains(TIER_1_GATEWAY_NAME)); + Assert.assertSame(transportFailure, exception.getCause()); + verify(nsxService, never()).apply(StaticRoutes.class); + verify(nsxService, never()).apply(NatRules.class); + verify(nsxService, never()).apply(IpsecVpnServices.class); + } + + @Test + public void testDeleteVpnServiceContinuesAfterOneSessionIsAlreadyMissing() { + Tier1s tier1s = Mockito.mock(Tier1s.class); + StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); + NatRules natRules = Mockito.mock(NatRules.class); + IpsecVpnServices vpnServices = Mockito.mock(IpsecVpnServices.class); + Sessions sessions = Mockito.mock(Sessions.class); + LocalEndpoints localEndpoints = Mockito.mock(LocalEndpoints.class); + StaticRoutesListResult staticRoutesResult = Mockito.mock(StaticRoutesListResult.class); + PolicyNatRuleListResult natRulesResult = Mockito.mock(PolicyNatRuleListResult.class); + IPSecVpnServiceListResult vpnServicesResult = Mockito.mock(IPSecVpnServiceListResult.class); + IPSecVpnSessionListResult sessionsResult = Mockito.mock(IPSecVpnSessionListResult.class); + IPSecVpnLocalEndpointListResult localEndpointsResult = Mockito.mock(IPSecVpnLocalEndpointListResult.class); + IPSecVpnService vpnService = Mockito.mock(IPSecVpnService.class); + IPSecVpnSession missingSession = Mockito.mock(IPSecVpnSession.class); + IPSecVpnSession remainingSession = Mockito.mock(IPSecVpnSession.class); + IPSecVpnLocalEndpoint localEndpoint = Mockito.mock(IPSecVpnLocalEndpoint.class); + + when(nsxService.apply(Tier1s.class)).thenReturn(tier1s); + when(nsxService.apply(StaticRoutes.class)).thenReturn(staticRoutes); + when(nsxService.apply(NatRules.class)).thenReturn(natRules); + when(nsxService.apply(IpsecVpnServices.class)).thenReturn(vpnServices); + when(nsxService.apply(Sessions.class)).thenReturn(sessions); + when(nsxService.apply(LocalEndpoints.class)).thenReturn(localEndpoints); + when(tier1s.get(TIER_1_GATEWAY_NAME)).thenReturn(Mockito.mock(Tier1.class)); + when(staticRoutes.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) + .thenReturn(staticRoutesResult); + when(staticRoutesResult.getResults()).thenReturn(List.of()); + when(natRules.list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class))) + .thenReturn(natRulesResult); + when(natRulesResult.getResults()).thenReturn(List.of()); + when(natRules.get(TIER_1_GATEWAY_NAME, "USER", "t1-NAT")) + .thenThrow(new NotFound(null, null)); + when(vpnServices.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), eq(false), nullable(String.class))) + .thenReturn(vpnServicesResult); + when(vpnService.getId()).thenReturn("t1-vpn"); + when(vpnServicesResult.getResults()).thenReturn(List.of(vpnService)); + when(sessions.list(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), eq(false), nullable(String.class))) + .thenReturn(sessionsResult); + when(missingSession.getId()).thenReturn("operator-missing-session"); + when(remainingSession.getId()).thenReturn("operator-remaining-session"); + when(missingSession._convertTo(IPSecVpnSession.class)).thenReturn(missingSession); + when(remainingSession._convertTo(IPSecVpnSession.class)).thenReturn(remainingSession); + when(sessionsResult.getResults()).thenReturn(List.of(missingSession, remainingSession)); + doThrow(new NotFound(null, null)).when(sessions) + .delete(TIER_1_GATEWAY_NAME, "t1-vpn", "operator-missing-session"); + when(localEndpoints.list(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), eq(false), nullable(String.class))) + .thenReturn(localEndpointsResult); + when(localEndpoint.getId()).thenReturn("t1-vpn-local-endpoint"); + when(localEndpointsResult.getResults()).thenReturn(List.of(localEndpoint)); + + client.deleteVpnService(TIER_1_GATEWAY_NAME); + + verify(sessions).delete(TIER_1_GATEWAY_NAME, "t1-vpn", "operator-remaining-session"); + verify(localEndpoints).delete(TIER_1_GATEWAY_NAME, "t1-vpn", "t1-vpn-local-endpoint"); + verify(vpnServices).delete(TIER_1_GATEWAY_NAME, "t1-vpn"); + } + @Test public void testCreateRouteBasedVpnSessionRemovesSessionWhenPatchFails() { IpsecVpnIkeProfiles ikeProfiles = Mockito.mock(IpsecVpnIkeProfiles.class); @@ -543,14 +648,14 @@ public void testCreateRouteBasedVpnSessionRemovesSessionWhenPatchFails() { doThrow(new Error(List.of(), errorData)).when(sessions).patch(anyString(), anyString(), anyString(), any(RouteBasedIPSecVpnSession.class)); assertThrows(CloudRuntimeException.class, () -> client.createRouteBasedVpnSession( - TIER_1_GATEWAY_NAME, "connection-uuid", "203.0.113.10", "psk", + TIER_1_GATEWAY_NAME, CONNECTION_ID, "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, "169.254.64.21", 30)); - verify(sessions).delete(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), eq("cs-conn-connection-uuid")); - verify(ikeProfiles).delete("cs-conn-connection-uuid-ike"); - verify(tunnelProfiles).delete("cs-conn-connection-uuid-esp"); - verify(dpdProfiles).delete("cs-conn-connection-uuid-dpd"); + verify(sessions).delete(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), eq("cs-conn-5")); + verify(ikeProfiles).delete("cs-conn-5-ike"); + verify(tunnelProfiles).delete("cs-conn-5-esp"); + verify(dpdProfiles).delete("cs-conn-5-dpd"); } @Test @@ -570,16 +675,16 @@ public void testCreateRouteBasedVpnSessionLeavesExistingSessionDisabledWhenPatch Mockito.when(nsxService.apply(IpsecVpnTunnelProfiles.class)).thenReturn(tunnelProfiles); Mockito.when(nsxService.apply(IpsecVpnDpdProfiles.class)).thenReturn(dpdProfiles); Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); - Mockito.when(sessions.get(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + Mockito.when(sessions.get(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5")) .thenReturn(existingSession); - Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5")) .thenReturn(existingSession); mockEmptyVpnConnectionRouteLists(staticRoutes, natRules); Mockito.when(errorData._convertTo(ApiError.class)).thenReturn(apiError); doThrow(new Error(List.of(), errorData)).when(sessions).patch(anyString(), anyString(), anyString(), any(RouteBasedIPSecVpnSession.class)); assertThrows(CloudRuntimeException.class, () -> client.createRouteBasedVpnSession( - TIER_1_GATEWAY_NAME, "connection-uuid", "203.0.113.10", "psk", + TIER_1_GATEWAY_NAME, CONNECTION_ID, "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, "169.254.64.21", 30)); @@ -590,8 +695,8 @@ public void testCreateRouteBasedVpnSessionLeavesExistingSessionDisabledWhenPatch ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Structure.class); InOrder inOrder = Mockito.inOrder(sessions, ikeProfiles); inOrder.verify(sessions).update(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), - eq("cs-conn-connection-uuid"), updateCaptor.capture()); - inOrder.verify(ikeProfiles).patch(eq("cs-conn-connection-uuid-ike"), any(IPSecVpnIkeProfile.class)); + eq("cs-conn-5"), updateCaptor.capture()); + inOrder.verify(ikeProfiles).patch(eq("cs-conn-5-ike"), any(IPSecVpnIkeProfile.class)); RouteBasedIPSecVpnSession update = updateCaptor.getValue()._convertTo(RouteBasedIPSecVpnSession.class); assertFalse(update.getEnabled()); } @@ -615,14 +720,14 @@ public void testCreateRouteBasedVpnSessionCleansProfilesWhenProfilePatchFails() .patch(anyString(), any(IPSecVpnTunnelProfile.class)); assertThrows(CloudRuntimeException.class, () -> client.createRouteBasedVpnSession( - TIER_1_GATEWAY_NAME, "connection-uuid", "203.0.113.10", "psk", + TIER_1_GATEWAY_NAME, CONNECTION_ID, "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, "169.254.64.21", 30)); - verify(sessions).delete(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid"); - verify(ikeProfiles).delete("cs-conn-connection-uuid-ike"); - verify(tunnelProfiles).delete("cs-conn-connection-uuid-esp"); - verify(dpdProfiles).delete("cs-conn-connection-uuid-dpd"); + verify(sessions).delete(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5"); + verify(ikeProfiles).delete("cs-conn-5-ike"); + verify(tunnelProfiles).delete("cs-conn-5-esp"); + verify(dpdProfiles).delete("cs-conn-5-dpd"); } @Test @@ -639,15 +744,15 @@ public void testCreateRouteBasedVpnSessionPreservesPreExistingProfilesAfterFailu Mockito.when(nsxService.apply(IpsecVpnTunnelProfiles.class)).thenReturn(tunnelProfiles); Mockito.when(nsxService.apply(IpsecVpnDpdProfiles.class)).thenReturn(dpdProfiles); Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); - Mockito.when(ikeProfiles.get("cs-conn-connection-uuid-ike")).thenReturn(Mockito.mock(IPSecVpnIkeProfile.class)); - Mockito.when(tunnelProfiles.get("cs-conn-connection-uuid-esp")).thenReturn(Mockito.mock(IPSecVpnTunnelProfile.class)); - Mockito.when(dpdProfiles.get("cs-conn-connection-uuid-dpd")).thenReturn(Mockito.mock(IPSecVpnDpdProfile.class)); + Mockito.when(ikeProfiles.get("cs-conn-5-ike")).thenReturn(Mockito.mock(IPSecVpnIkeProfile.class)); + Mockito.when(tunnelProfiles.get("cs-conn-5-esp")).thenReturn(Mockito.mock(IPSecVpnTunnelProfile.class)); + Mockito.when(dpdProfiles.get("cs-conn-5-dpd")).thenReturn(Mockito.mock(IPSecVpnDpdProfile.class)); Mockito.when(errorData._convertTo(ApiError.class)).thenReturn(apiError); doThrow(new Error(List.of(), errorData)).when(sessions) .patch(anyString(), anyString(), anyString(), any(RouteBasedIPSecVpnSession.class)); assertThrows(CloudRuntimeException.class, () -> client.createRouteBasedVpnSession( - TIER_1_GATEWAY_NAME, "connection-uuid", "203.0.113.10", "psk", + TIER_1_GATEWAY_NAME, CONNECTION_ID, "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, "169.254.64.21", 30)); @@ -668,21 +773,21 @@ public void testCreateRouteBasedVpnSessionReportsWhetherSessionWasPreexisting() Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); assertEquals(NsxApiClient.VpnSessionProvisioningResult.CREATED, client.createRouteBasedVpnSession( - TIER_1_GATEWAY_NAME, "new-connection", "203.0.113.10", "psk", + TIER_1_GATEWAY_NAME, CONNECTION_ID, "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, "169.254.64.21", 30)); - Mockito.when(sessions.get(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-existing-connection")) + Mockito.when(sessions.get(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-6")) .thenReturn(Mockito.mock(Structure.class)); RouteBasedIPSecVpnSession existingSession = createCompleteVpnSession("secret-psk"); - existingSession.setId("cs-conn-existing-connection"); - Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-existing-connection")) + existingSession.setId("cs-conn-6"); + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-6")) .thenReturn(existingSession); StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); NatRules natRules = Mockito.mock(NatRules.class); mockEmptyVpnConnectionRouteLists(staticRoutes, natRules); assertEquals(NsxApiClient.VpnSessionProvisioningResult.PREEXISTING, client.createRouteBasedVpnSession( - TIER_1_GATEWAY_NAME, "existing-connection", "203.0.113.10", "psk", + TIER_1_GATEWAY_NAME, EXISTING_CONNECTION_ID, "203.0.113.10", "psk", "aes256-sha256;modp2048", "aes256-sha256;modp2048", 86400L, 3600L, true, "ikev2", false, "169.254.64.25", 30)); @@ -704,17 +809,17 @@ public void testUpdateVpnConnectionStateUsesSensitiveFullReplace() { NatRules natRules = Mockito.mock(NatRules.class); RouteBasedIPSecVpnSession session = createCompleteVpnSession("secret-psk"); Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); - Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5")) .thenReturn(session); mockEmptyVpnConnectionRouteLists(staticRoutes, natRules); - client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false); + client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, CONNECTION_ID, false); ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Structure.class); InOrder inOrder = Mockito.inOrder(sessions, staticRoutes, natRules); - inOrder.verify(sessions).showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid"); + inOrder.verify(sessions).showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5"); inOrder.verify(sessions).update(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), - eq("cs-conn-connection-uuid"), updateCaptor.capture()); + eq("cs-conn-5"), updateCaptor.capture()); inOrder.verify(staticRoutes).list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), nullable(String.class), nullable(Long.class), nullable(Boolean.class), nullable(String.class)); inOrder.verify(natRules).list(eq(TIER_1_GATEWAY_NAME), anyString(), nullable(String.class), eq(false), @@ -739,14 +844,14 @@ public void testUpdateVpnConnectionStateDoesNotCleanupWhenPutFails() { ApiError apiError = new ApiError(); apiError.setErrorMessage("update failed"); Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); - Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5")) .thenReturn(createCompleteVpnSession("secret-psk")); Mockito.when(errorData._convertTo(ApiError.class)).thenReturn(apiError); doThrow(new Error(List.of(), errorData)).when(sessions) .update(anyString(), anyString(), anyString(), any(Structure.class)); CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, - () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false)); + () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, CONNECTION_ID, false)); assertFalse(exception.getMessage().contains("secret-psk")); verify(nsxService, never()).apply(StaticRoutes.class); @@ -757,11 +862,11 @@ public void testUpdateVpnConnectionStateDoesNotCleanupWhenPutFails() { public void testUpdateVpnConnectionStateRejectsMissingSensitivePsk() { Sessions sessions = Mockito.mock(Sessions.class); Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); - Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5")) .thenReturn(createCompleteVpnSession(null)); CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, - () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false)); + () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, CONNECTION_ID, false)); assertTrue(exception.getMessage().contains("did not return sensitive authentication data")); verify(sessions, never()).update(anyString(), anyString(), anyString(), any(Structure.class)); @@ -774,11 +879,11 @@ public void testUpdateVpnConnectionStateRejectsNonRouteBasedSession() { Sessions sessions = Mockito.mock(Sessions.class); Structure session = Mockito.mock(Structure.class); Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); - Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5")) .thenReturn(session); CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, - () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false)); + () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, CONNECTION_ID, false)); assertTrue(exception.getMessage().contains("is not route-based")); verify(sessions, never()).update(anyString(), anyString(), anyString(), any(Structure.class)); @@ -792,11 +897,11 @@ public void testUpdateVpnConnectionStateRejectsMissingRevision() { RouteBasedIPSecVpnSession session = createCompleteVpnSession("secret-psk"); session.setRevision(null); Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); - Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5")) .thenReturn(session); CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, - () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false)); + () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, CONNECTION_ID, false)); assertTrue(exception.getMessage().contains("returned no revision")); verify(sessions, never()).update(anyString(), anyString(), anyString(), any(Structure.class)); @@ -804,17 +909,65 @@ public void testUpdateVpnConnectionStateRejectsMissingRevision() { verify(nsxService, never()).apply(NatRules.class); } + @Test + public void testGetRouteBasedVpnSessionLocalVtiIpsParsesLiveSessionResultsAndExcludesCurrentConnection() { + Sessions sessions = Mockito.mock(Sessions.class); + IPSecVpnSessionListResult listResult = new IPSecVpnSessionListResult(); + RouteBasedIPSecVpnSession currentSession = createCompleteVpnSession("current-psk"); + RouteBasedIPSecVpnSession otherSession = createCompleteVpnSession("other-psk"); + otherSession.setId("cs-conn-6"); + otherSession.setDisplayName("cs-conn-6"); + otherSession.getTunnelInterfaces().get(0).getIpSubnets().get(0) + .setIpAddresses(List.of("169.254.64.25")); + Structure nonRouteBasedSession = Mockito.mock(Structure.class); + listResult.setResults(List.of(currentSession, otherSession, nonRouteBasedSession)); + listResult.setResultCount(3L); + Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); + Mockito.when(sessions.list(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), eq(false), nullable(String.class))) + .thenReturn(listResult); + + Set vtiIps = client.getRouteBasedVpnSessionLocalVtiIps(TIER_1_GATEWAY_NAME, CONNECTION_ID); + + assertEquals(Set.of("169.254.64.25"), vtiIps); + verify(sessions).list(eq(TIER_1_GATEWAY_NAME), eq("t1-vpn"), nullable(String.class), eq(false), + nullable(String.class), nullable(Long.class), eq(false), nullable(String.class)); + verify(nonRouteBasedSession)._hasTypeNameOf(RouteBasedIPSecVpnSession.class); + verify(nonRouteBasedSession, never())._convertTo(RouteBasedIPSecVpnSession.class); + } + + @Test + public void testGetVpnSessionStatusReportsDegradedRegardlessOfResultOrder() { + DetailedStatus detailedStatus = Mockito.mock(DetailedStatus.class); + AggregateIPSecVpnSessionStatus aggregateStatus = new AggregateIPSecVpnSessionStatus(); + IPSecVpnSessionStatusNsxt up = new IPSecVpnSessionStatusNsxt(); + up.setRuntimeStatus(IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_UP); + IPSecVpnSessionStatusNsxt degraded = new IPSecVpnSessionStatusNsxt(); + degraded.setRuntimeStatus(IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_DEGRADED); + aggregateStatus.setResults(List.of(up, degraded)); + Mockito.when(nsxService.apply(DetailedStatus.class)).thenReturn(detailedStatus); + Mockito.when(detailedStatus.get(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5", null, null)) + .thenReturn(aggregateStatus); + + assertEquals(IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_DEGRADED, + client.getVpnSessionStatus(TIER_1_GATEWAY_NAME, CONNECTION_ID)); + + aggregateStatus.setResults(List.of(degraded, up)); + assertEquals(IPSecVpnSessionStatusNsxt.RUNTIME_STATUS_DEGRADED, + client.getVpnSessionStatus(TIER_1_GATEWAY_NAME, CONNECTION_ID)); + } + @Test public void testDisableMissingVpnSessionStillCleansStaleRoutesAndNat() { Sessions sessions = Mockito.mock(Sessions.class); StaticRoutes staticRoutes = Mockito.mock(StaticRoutes.class); NatRules natRules = Mockito.mock(NatRules.class); Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); - Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5")) .thenThrow(new NotFound(null, null)); mockEmptyVpnConnectionRouteLists(staticRoutes, natRules); - client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false); + client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, CONNECTION_ID, false); verify(sessions, never()).update(anyString(), anyString(), anyString(), any(Structure.class)); verify(staticRoutes).list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), @@ -827,11 +980,11 @@ public void testDisableMissingVpnSessionStillCleansStaleRoutesAndNat() { public void testEnableMissingVpnSessionFailsWithoutRouteCleanup() { Sessions sessions = Mockito.mock(Sessions.class); Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); - Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5")) .thenThrow(new NotFound(null, null)); CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, - () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", true)); + () -> client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, CONNECTION_ID, true)); assertTrue(exception.getMessage().contains("because it does not exist")); verify(sessions, never()).update(anyString(), anyString(), anyString(), any(Structure.class)); @@ -850,12 +1003,12 @@ public void testVpnRouteCleanupContinuesWhenIndividualObjectsAreAlreadyAbsent() com.vmware.nsx_policy.model.StaticRoutes secondRoute = Mockito.mock(com.vmware.nsx_policy.model.StaticRoutes.class); PolicyNatRule firstRule = Mockito.mock(PolicyNatRule.class); PolicyNatRule secondRule = Mockito.mock(PolicyNatRule.class); - Mockito.when(firstRoute.getId()).thenReturn("cs-conn-connection-uuid-route0"); - Mockito.when(secondRoute.getId()).thenReturn("cs-conn-connection-uuid-route1"); - Mockito.when(firstRule.getId()).thenReturn("cs-conn-connection-uuid-nosnat0"); - Mockito.when(secondRule.getId()).thenReturn("cs-conn-connection-uuid-nosnat1"); + Mockito.when(firstRoute.getId()).thenReturn("cs-conn-5-route0"); + Mockito.when(secondRoute.getId()).thenReturn("cs-conn-5-route1"); + Mockito.when(firstRule.getId()).thenReturn("cs-conn-5-nosnat0"); + Mockito.when(secondRule.getId()).thenReturn("cs-conn-5-nosnat1"); Mockito.when(nsxService.apply(Sessions.class)).thenReturn(sessions); - Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid")) + Mockito.when(sessions.showsensitivedata(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5")) .thenThrow(new NotFound(null, null)); Mockito.when(nsxService.apply(StaticRoutes.class)).thenReturn(staticRoutes); Mockito.when(staticRoutes.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), @@ -868,16 +1021,16 @@ public void testVpnRouteCleanupContinuesWhenIndividualObjectsAreAlreadyAbsent() .thenReturn(ruleList); Mockito.when(ruleList.getResults()).thenReturn(List.of(firstRule, secondRule)); doThrow(new NotFound(null, null)).when(staticRoutes) - .delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route0"); + .delete(TIER_1_GATEWAY_NAME, "cs-conn-5-route0"); doThrow(new NotFound(null, null)).when(natRules) - .delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat0"); + .delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-5-nosnat0"); - client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, "connection-uuid", false); + client.updateVpnConnectionState(TIER_1_GATEWAY_NAME, CONNECTION_ID, false); - verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route0"); - verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route1"); - verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat0"); - verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat1"); + verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-5-route0"); + verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-5-route1"); + verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-5-nosnat0"); + verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-5-nosnat1"); } @Test @@ -890,10 +1043,10 @@ public void testAddVpnConnectionRoutesPatchesDesiredBeforeDeletingStale() { com.vmware.nsx_policy.model.StaticRoutes staleRoute = Mockito.mock(com.vmware.nsx_policy.model.StaticRoutes.class); PolicyNatRule desiredRule = Mockito.mock(PolicyNatRule.class); PolicyNatRule staleRule = Mockito.mock(PolicyNatRule.class); - Mockito.when(desiredRoute.getId()).thenReturn("cs-conn-connection-uuid-route0"); - Mockito.when(staleRoute.getId()).thenReturn("cs-conn-connection-uuid-route1"); - Mockito.when(desiredRule.getId()).thenReturn("cs-conn-connection-uuid-nosnat0"); - Mockito.when(staleRule.getId()).thenReturn("cs-conn-connection-uuid-nosnat1"); + Mockito.when(desiredRoute.getId()).thenReturn("cs-conn-5-route0"); + Mockito.when(staleRoute.getId()).thenReturn("cs-conn-5-route1"); + Mockito.when(desiredRule.getId()).thenReturn("cs-conn-5-nosnat0"); + Mockito.when(staleRule.getId()).thenReturn("cs-conn-5-nosnat1"); Mockito.when(nsxService.apply(StaticRoutes.class)).thenReturn(staticRoutes); Mockito.when(nsxService.apply(NatRules.class)).thenReturn(natRules); Mockito.when(staticRoutes.list(eq(TIER_1_GATEWAY_NAME), nullable(String.class), eq(false), @@ -905,18 +1058,18 @@ public void testAddVpnConnectionRoutesPatchesDesiredBeforeDeletingStale() { .thenReturn(ruleList); Mockito.when(ruleList.getResults()).thenReturn(List.of(desiredRule, staleRule)); - client.addVpnConnectionRoutes(TIER_1_GATEWAY_NAME, "connection-uuid", + client.addVpnConnectionRoutes(TIER_1_GATEWAY_NAME, CONNECTION_ID, List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16"); InOrder inOrder = Mockito.inOrder(staticRoutes, natRules); - inOrder.verify(staticRoutes).patch(eq(TIER_1_GATEWAY_NAME), eq("cs-conn-connection-uuid-route0"), + inOrder.verify(staticRoutes).patch(eq(TIER_1_GATEWAY_NAME), eq("cs-conn-5-route0"), any(com.vmware.nsx_policy.model.StaticRoutes.class)); inOrder.verify(natRules).patch(eq(TIER_1_GATEWAY_NAME), anyString(), - eq("cs-conn-connection-uuid-nosnat0"), any(PolicyNatRule.class)); - inOrder.verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route1"); - inOrder.verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat1"); - verify(staticRoutes, never()).delete(TIER_1_GATEWAY_NAME, "cs-conn-connection-uuid-route0"); - verify(natRules, never()).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-connection-uuid-nosnat0"); + eq("cs-conn-5-nosnat0"), any(PolicyNatRule.class)); + inOrder.verify(staticRoutes).delete(TIER_1_GATEWAY_NAME, "cs-conn-5-route1"); + inOrder.verify(natRules).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-5-nosnat1"); + verify(staticRoutes, never()).delete(TIER_1_GATEWAY_NAME, "cs-conn-5-route0"); + verify(natRules, never()).delete(TIER_1_GATEWAY_NAME, "USER", "cs-conn-5-nosnat0"); } @Test @@ -929,7 +1082,7 @@ public void testAddVpnConnectionRoutesPatchFailureDoesNotDeleteExistingResources .patch(anyString(), anyString(), any(com.vmware.nsx_policy.model.StaticRoutes.class)); assertThrows(CloudRuntimeException.class, () -> client.addVpnConnectionRoutes(TIER_1_GATEWAY_NAME, - "connection-uuid", List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16")); + CONNECTION_ID, List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16")); verify(staticRoutes, never()).list(anyString(), any(), anyBoolean(), any(), any(), any(), any()); verify(natRules, never()).list(anyString(), anyString(), any(), anyBoolean(), any(), any(), any(), any()); @@ -948,13 +1101,13 @@ public void testAddVpnConnectionRoutesRetriesMarkedForDeletion() { .doNothing() .when(staticRoutes).patch(anyString(), anyString(), any(com.vmware.nsx_policy.model.StaticRoutes.class)); - client.addVpnConnectionRoutes(TIER_1_GATEWAY_NAME, "connection-uuid", + client.addVpnConnectionRoutes(TIER_1_GATEWAY_NAME, CONNECTION_ID, List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16"); verify(staticRoutes, Mockito.times(2)).patch(eq(TIER_1_GATEWAY_NAME), - eq("cs-conn-connection-uuid-route0"), any(com.vmware.nsx_policy.model.StaticRoutes.class)); + eq("cs-conn-5-route0"), any(com.vmware.nsx_policy.model.StaticRoutes.class)); verify(natRules).patch(eq(TIER_1_GATEWAY_NAME), anyString(), - eq("cs-conn-connection-uuid-nosnat0"), any(PolicyNatRule.class)); + eq("cs-conn-5-nosnat0"), any(PolicyNatRule.class)); } @Test @@ -972,12 +1125,12 @@ public void testDeleteVpnConnectionContinuesCleanupAfterRouteAndNatFailures() { Mockito.when(nsxService.apply(IpsecVpnDpdProfiles.class)).thenReturn(dpdProfiles); assertThrows(CloudRuntimeException.class, - () -> client.deleteVpnConnection(TIER_1_GATEWAY_NAME, "connection-uuid")); + () -> client.deleteVpnConnection(TIER_1_GATEWAY_NAME, CONNECTION_ID)); - verify(sessions).delete(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-connection-uuid"); - verify(ikeProfiles).delete("cs-conn-connection-uuid-ike"); - verify(tunnelProfiles).delete("cs-conn-connection-uuid-esp"); - verify(dpdProfiles).delete("cs-conn-connection-uuid-dpd"); + verify(sessions).delete(TIER_1_GATEWAY_NAME, "t1-vpn", "cs-conn-5"); + verify(ikeProfiles).delete("cs-conn-5-ike"); + verify(tunnelProfiles).delete("cs-conn-5-esp"); + verify(dpdProfiles).delete("cs-conn-5-dpd"); } private LbMonitorProfiles mockLbMonitorProfiles() { @@ -1000,8 +1153,8 @@ private RouteBasedIPSecVpnSession createCompleteVpnSession(String psk) { .build())) .build(); RouteBasedIPSecVpnSession session = new RouteBasedIPSecVpnSession.Builder() - .setId("cs-conn-connection-uuid") - .setDisplayName("cs-conn-connection-uuid") + .setId("cs-conn-5") + .setDisplayName("cs-conn-5") .setEnabled(true) .setAuthenticationMode(IPSecVpnSession.AUTHENTICATION_MODE_PSK) .setPsk(psk) diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java index b8a5aa3c5b51..a0055828771f 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java @@ -59,7 +59,7 @@ import com.cloud.network.vpc.NetworkACLItem; import com.cloud.network.vpc.NetworkACLItemVO; import com.cloud.network.vpc.Vpc; -import com.cloud.network.vpc.VpcOfferingServiceMapVO; +import com.cloud.network.vpc.VpcManager; import com.cloud.network.vpc.VpcService; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; @@ -160,6 +160,8 @@ public class NsxElementTest { @Mock VpcService vpcService; @Mock + VpcManager vpcManager; + @Mock Site2SiteVpnGatewayDao vpnGatewayDao; @Mock Site2SiteCustomerGatewayDao customerGatewayDao; @@ -197,6 +199,7 @@ public void setup() throws NoSuchFieldException, IllegalAccessException { nsxElement.firewallRuleDetailsDao = firewallRuleDetailsDao; nsxElement.ipAddressManager = ipAddressManager; nsxElement.vpcService = vpcService; + nsxElement.vpcManager = vpcManager; nsxElement.vpnGatewayDao = vpnGatewayDao; nsxElement.customerGatewayDao = customerGatewayDao; nsxElement.userIpAddressDetailsDao = userIpAddressDetailsDao; @@ -528,9 +531,8 @@ public void testRevokeFirewallRules() throws ResourceUnavailableException { private VpcVO mockVpcWithNsxVpnSupport() { VpcVO vpcVO = Mockito.mock(VpcVO.class); Mockito.lenient().when(vpcVO.getId()).thenReturn(9L); - when(vpcVO.getVpcOfferingId()).thenReturn(11L); - when(vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId(Network.Service.Vpn.getName(), - Network.Provider.Nsx.getName(), 11L)).thenReturn(Mockito.mock(VpcOfferingServiceMapVO.class)); + when(vpcManager.isProviderSupportServiceInVpc(9L, Network.Service.Vpn, Network.Provider.Nsx)) + .thenReturn(true); return vpcVO; } @@ -552,7 +554,7 @@ public void testAcquireVpnGatewayIpWithRequestedIp() { when(ipAddressVO.getId()).thenReturn(20L); when(ipAddressVO.getVpcId()).thenReturn(9L); when(loadBalancerDao.listByIpAddress(20L)).thenReturn(List.of()); - when(nsxService.createVpnGateway(vpcVO, "10.1.13.20")).thenReturn(new NsxVpnGatewayResult(true, true)); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.20", false)).thenReturn(new NsxVpnGatewayResult(true, true)); IpAddress result = nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp); assertEquals(ipAddressVO, result); @@ -574,7 +576,7 @@ public void testAcquireVpnGatewayIpDoesNotCallNsxWhenRequestedIpOwnershipCannotB Assert.assertThrows(CloudRuntimeException.class, () -> nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp)); - verify(nsxService, never()).createVpnGateway(any(Vpc.class), anyString()); + verify(nsxService, never()).createVpnGateway(any(Vpc.class), anyString(), anyBoolean()); } @Test @@ -586,7 +588,7 @@ public void testAcquireVpnGatewayIpRemovesRequestedIpOwnershipWhenNsxRejectsGate when(ipAddressVO.getId()).thenReturn(20L); when(ipAddressVO.getVpcId()).thenReturn(9L); when(loadBalancerDao.listByIpAddress(20L)).thenReturn(List.of()); - when(nsxService.createVpnGateway(vpcVO, "10.1.13.20")).thenReturn(new NsxVpnGatewayResult(false, false)); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.20", false)).thenReturn(new NsxVpnGatewayResult(false, false)); Assert.assertThrows(CloudRuntimeException.class, () -> nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp)); @@ -603,12 +605,35 @@ public void testAcquireVpnGatewayIpRetainsRequestedIpOwnershipWhenNsxResultIsAmb when(ipAddressVO.getId()).thenReturn(20L); when(ipAddressVO.getVpcId()).thenReturn(9L); when(loadBalancerDao.listByIpAddress(20L)).thenReturn(List.of()); - when(nsxService.createVpnGateway(vpcVO, "10.1.13.20")).thenReturn(new NsxVpnGatewayResult(false, true)); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.20", false)).thenReturn(new NsxVpnGatewayResult(false, true)); + + Assert.assertThrows(CloudRuntimeException.class, + () -> nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp)); + + verify(userIpAddressDetailsDao, never()).removeDetail(20L, "nsxVpnGatewayIp"); + } + + @Test + public void testAcquireVpnGatewayIpPreservesExistingRequestedIpOwnershipOnUnambiguousFailure() { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + IpAddress requestedIp = Mockito.mock(IpAddress.class); + when(requestedIp.getId()).thenReturn(20L); + IPAddressVO ipAddressVO = mockIpAddressVO(20L, "10.1.13.20"); + when(ipAddressVO.getId()).thenReturn(20L); + when(ipAddressVO.getVpcId()).thenReturn(9L); + when(loadBalancerDao.listByIpAddress(20L)).thenReturn(List.of()); + UserIpAddressDetailVO existingMarker = new UserIpAddressDetailVO( + 20L, "nsxVpnGatewayIp", "false", false); + when(userIpAddressDetailsDao.findDetail(20L, "nsxVpnGatewayIp")).thenReturn(existingMarker); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.20", true)) + .thenReturn(new NsxVpnGatewayResult(false, false)); Assert.assertThrows(CloudRuntimeException.class, () -> nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp)); + verify(userIpAddressDetailsDao, never()).addDetail(anyLong(), anyString(), anyString(), anyBoolean()); verify(userIpAddressDetailsDao, never()).removeDetail(20L, "nsxVpnGatewayIp"); + verify(nsxService).createVpnGateway(vpcVO, "10.1.13.20", true); } @Test(expected = InvalidParameterValueException.class) @@ -657,7 +682,6 @@ public void testAcquireVpnGatewayIpRejectsAnUnallocatedIp() { @Test public void testAcquireVpnGatewayIpReturnsNullWhenVpnIsNotProvidedByNsx() { VpcVO vpcVO = Mockito.mock(VpcVO.class); - when(vpcVO.getVpcOfferingId()).thenReturn(11L); assertNull(nsxElement.acquireVpnGatewayIp(vpcVO, null)); } @@ -673,7 +697,7 @@ public void testAcquireVpnGatewayIpAutoAcquiresWhenNoIpIsRequested() throws Exce when(allocatedIp.getId()).thenReturn(30L); when(ipAddressManager.allocateIp(any(), anyBoolean(), any(), any(), any(), any(), any())).thenReturn(allocatedIp); IPAddressVO ipAddressVO = mockIpAddressVO(30L, "10.1.13.30"); - when(nsxService.createVpnGateway(vpcVO, "10.1.13.30")).thenReturn(new NsxVpnGatewayResult(true, true)); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.30", false)).thenReturn(new NsxVpnGatewayResult(true, true)); IpAddress result = nsxElement.acquireVpnGatewayIp(vpcVO, null); assertEquals(ipAddressVO, result); @@ -684,6 +708,32 @@ public void testAcquireVpnGatewayIpAutoAcquiresWhenNoIpIsRequested() throws Exce } } + @Test + public void testAcquireVpnGatewayIpReusesRetainedIpAfterAmbiguousCreate() throws Exception { + CallContext.register(Mockito.mock(User.class), Mockito.mock(Account.class)); + try { + VpcVO vpcVO = mockVpcWithNsxVpnSupport(); + IPAddressVO retainedIp = mockIpAddressVO(32L, "10.1.13.32"); + when(retainedIp.getId()).thenReturn(32L); + when(retainedIp.getVpcId()).thenReturn(9L); + UserIpAddressDetailVO retainedMarker = new UserIpAddressDetailVO( + 32L, "nsxVpnGatewayIp", "true", false); + when(ipAddressDao.listByAssociatedVpc(9L, false)).thenReturn(List.of(retainedIp)); + when(userIpAddressDetailsDao.findDetail(32L, "nsxVpnGatewayIp")).thenReturn(retainedMarker); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.32", true)) + .thenReturn(new NsxVpnGatewayResult(true, true)); + + IpAddress result = nsxElement.acquireVpnGatewayIp(vpcVO, null); + + assertEquals(retainedIp, result); + verify(nsxService).createVpnGateway(vpcVO, "10.1.13.32", true); + verify(ipAddressManager, never()).allocateIp(any(), anyBoolean(), any(), any(), any(), any(), any()); + verify(vpcService, never()).associateIPToVpc(anyLong(), anyLong()); + } finally { + CallContext.unregister(); + } + } + @Test(expected = CloudRuntimeException.class) public void testAcquireVpnGatewayIpReleasesAutoAcquiredIpWhenNsxRejectsGateway() throws Exception { CallContext.register(Mockito.mock(User.class), Mockito.mock(Account.class)); @@ -696,7 +746,7 @@ public void testAcquireVpnGatewayIpReleasesAutoAcquiredIpWhenNsxRejectsGateway() when(ipAddressManager.allocateIp(any(), anyBoolean(), any(), any(), any(), any(), any())).thenReturn(allocatedIp); IPAddressVO ipAddressVO = mockIpAddressVO(31L, "10.1.13.31"); when(ipAddressVO.getId()).thenReturn(31L); - when(nsxService.createVpnGateway(vpcVO, "10.1.13.31")).thenReturn(new NsxVpnGatewayResult(false, false)); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.31", false)).thenReturn(new NsxVpnGatewayResult(false, false)); when(ipAddressManager.disassociatePublicIpAddress(any(IPAddressVO.class), anyLong(), any())).thenReturn(true); nsxElement.acquireVpnGatewayIp(vpcVO, null); @@ -718,7 +768,7 @@ public void testAcquireVpnGatewayIpRetainsAutoAcquiredIpWhenEndpointMayBeInUse() when(allocatedIp.getId()).thenReturn(32L); when(ipAddressManager.allocateIp(any(), anyBoolean(), any(), any(), any(), any(), any())).thenReturn(allocatedIp); mockIpAddressVO(32L, "10.1.13.32"); - when(nsxService.createVpnGateway(vpcVO, "10.1.13.32")).thenReturn(new NsxVpnGatewayResult(false, true)); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.32", false)).thenReturn(new NsxVpnGatewayResult(false, true)); nsxElement.acquireVpnGatewayIp(vpcVO, null); } finally { @@ -768,6 +818,27 @@ public void testReleaseVpnGatewayIpKeepsOperatorSpecifiedIp() { verify(ipAddressManager, Mockito.never()).disassociatePublicIpAddress(any(), anyLong(), any()); } + @Test + public void testReleaseVpnGatewayIpRejectsInvalidOwnershipStateWhenVpcRowIsGone() { + when(vpcDao.findById(9L)).thenReturn(null); + Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); + when(vpnGateway.getVpcId()).thenReturn(9L); + when(vpnGateway.getAddrId()).thenReturn(30L); + IPAddressVO ipAddressVO = mockIpAddressVO(30L, "10.1.13.30"); + when(ipAddressVO.getId()).thenReturn(30L); + when(ipAddressVO.getVpcId()).thenReturn(9L); + UserIpAddressDetailVO detail = new UserIpAddressDetailVO(30L, "nsxVpnGatewayIp", "invalid", false); + when(userIpAddressDetailsDao.findDetail(30L, "nsxVpnGatewayIp")).thenReturn(detail); + + CloudRuntimeException exception = Assert.assertThrows(CloudRuntimeException.class, + () -> nsxElement.releaseVpnGatewayIp(vpnGateway)); + + assertTrue(exception.getMessage().contains("VPC 9")); + verify(nsxService, never()).deleteVpnGateway(any(Vpc.class)); + verify(userIpAddressDetailsDao, never()).removeDetail(30L, "nsxVpnGatewayIp"); + verify(ipAddressManager, never()).disassociatePublicIpAddress(any(), anyLong(), any()); + } + @Test public void testNsxVpnGatewayOwnershipDoesNotDependOnMarkerValue() { Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); @@ -830,19 +901,25 @@ public void testReleaseVpnGatewayIpRemovesOperatorMarkerWhenVpcRowIsGone() { verify(ipAddressManager, Mockito.never()).disassociatePublicIpAddress(any(), anyLong(), any()); } - @Test(expected = CloudRuntimeException.class) - public void testReleaseVpnGatewayIpDoesNotReleaseIpWhenNsxRejectsDeletion() { + @Test + public void testReleaseVpnGatewayIpPreservesOwnershipWhenNsxDeletionFails() { CallContext.register(Mockito.mock(User.class), Mockito.mock(Account.class)); try { VpcVO vpcVO = mockVpcWithNsxVpnSupport(); when(vpcDao.findById(9L)).thenReturn(vpcVO); Site2SiteVpnGateway vpnGateway = Mockito.mock(Site2SiteVpnGateway.class); when(vpnGateway.getVpcId()).thenReturn(9L); - when(nsxService.deleteVpnGateway(vpcVO)).thenReturn(false); + when(nsxService.deleteVpnGateway(vpcVO)) + .thenThrow(new CloudRuntimeException("NSX Tier-1 lookup failed")); - nsxElement.releaseVpnGatewayIp(vpnGateway); + CloudRuntimeException exception = Assert.assertThrows(CloudRuntimeException.class, + () -> nsxElement.releaseVpnGatewayIp(vpnGateway)); + + assertTrue(exception.getMessage().contains("Failed to delete the NSX VPN gateway")); + verify(ipAddressDao, never()).findById(30L); + verify(userIpAddressDetailsDao, never()).removeDetail(30L, "nsxVpnGatewayIp"); + verify(ipAddressManager, never()).disassociatePublicIpAddress(any(), anyLong(), any()); } finally { - verify(ipAddressManager, Mockito.never()).disassociatePublicIpAddress(any(), anyLong(), any()); CallContext.unregister(); } } @@ -904,7 +981,7 @@ private Site2SiteVpnConnection mockVpnConnection(VpcVO vpcVO, boolean nsxOwned) } @Test - public void testStartSite2SiteVpn() throws ResourceUnavailableException { + public void testStartSite2SiteVpnUsesImmutableConnectionId() throws ResourceUnavailableException { VpcVO vpcVO = mockVpcWithNsxVpnSupport(); Site2SiteVpnConnection connection = mockVpnConnection(vpcVO); when(connection.getId()).thenReturn(5L); @@ -914,7 +991,7 @@ public void testStartSite2SiteVpn() throws ResourceUnavailableException { Site2SiteCustomerGatewayVO customerGateway = mockCustomerGateway("aes256-sha256;modp2048", "aes128-sha1"); when(customerGateway.getGatewayIp()).thenReturn("203.0.113.10"); when(customerGateway.getGuestCidrList()).thenReturn("192.168.100.0/24,192.168.200.0/24"); - when(nsxService.createVpnConnection(any(Vpc.class), any(), anyString(), anyString(), anyString(), anyString(), + when(nsxService.createVpnConnection(any(Vpc.class), eq(5L), anyString(), anyString(), anyString(), anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyList(), eq("169.254.64.21"), eq("169.254.64.22"), anyInt(), eq("10.1.13.30"))).thenReturn(true); @@ -942,14 +1019,18 @@ public void testStartSite2SiteVpnRejectsDnsPeerAddress() throws ResourceUnavaila } @Test - public void testStartSite2SiteVpnIsNoOpWhenVpnIsNotProvidedByNsx() throws ResourceUnavailableException { + public void testSite2SiteVpnOperationsRejectGatewayNotOwnedByNsx() { VpcVO vpcVO = Mockito.mock(VpcVO.class); Site2SiteVpnConnection connection = mockVpnConnection(vpcVO, false); - assertTrue(nsxElement.startSite2SiteVpn(connection)); - verify(nsxService, Mockito.never()).createVpnConnection(any(Vpc.class), any(), anyString(), anyString(), + Assert.assertThrows(CloudRuntimeException.class, () -> nsxElement.startSite2SiteVpn(connection)); + Assert.assertThrows(CloudRuntimeException.class, () -> nsxElement.stopSite2SiteVpn(connection)); + Assert.assertThrows(CloudRuntimeException.class, () -> nsxElement.deleteSite2SiteVpn(connection)); + verify(nsxService, Mockito.never()).createVpnConnection(any(Vpc.class), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong(), anyLong(), anyBoolean(), anyString(), anyBoolean(), anyList(), anyString(), anyString(), anyInt(), anyString()); + verify(nsxService, Mockito.never()).updateVpnConnectionState(any(Vpc.class), anyLong(), anyBoolean()); + verify(nsxService, Mockito.never()).deleteVpnConnection(any(Vpc.class), anyLong()); } @Test(expected = CloudRuntimeException.class) @@ -963,8 +1044,8 @@ public void testStartSite2SiteVpnThrowsWhenVpcIsMissing() throws ResourceUnavail public void testStopSite2SiteVpn() throws ResourceUnavailableException { VpcVO vpcVO = mockVpcWithNsxVpnSupport(); Site2SiteVpnConnection connection = mockVpnConnection(vpcVO); - when(connection.getUuid()).thenReturn("conn-uuid"); - when(nsxService.updateVpnConnectionState(vpcVO, "conn-uuid", false)).thenReturn(true); + when(connection.getId()).thenReturn(5L); + when(nsxService.updateVpnConnectionState(vpcVO, 5L, false)).thenReturn(true); assertTrue(nsxElement.stopSite2SiteVpn(connection)); } @@ -980,8 +1061,8 @@ public void testStopSite2SiteVpnThrowsWhenVpcIsMissing() throws ResourceUnavaila public void testDeleteSite2SiteVpnRemovesTheProviderConnection() throws ResourceUnavailableException { VpcVO vpcVO = mockVpcWithNsxVpnSupport(); Site2SiteVpnConnection connection = mockVpnConnection(vpcVO); - when(connection.getUuid()).thenReturn("conn-uuid"); - when(nsxService.deleteVpnConnection(vpcVO, "conn-uuid")).thenReturn(true); + when(connection.getId()).thenReturn(5L); + when(nsxService.deleteVpnConnection(vpcVO, 5L)).thenReturn(true); assertTrue(nsxElement.deleteSite2SiteVpn(connection)); } diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java index 63b6bb8194e6..de1bcc4eac48 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java @@ -16,17 +16,20 @@ // under the License. package org.apache.cloudstack.service; +import com.cloud.alert.AlertManager; import com.cloud.network.IpAddress; -import com.cloud.network.dao.NetworkVO; import com.cloud.network.Site2SiteVpnConnection; -import com.cloud.network.dao.Site2SiteVpnConnectionVO; +import com.cloud.network.dao.NetworkVO; import com.cloud.network.dao.Site2SiteVpnConnectionDao; +import com.cloud.network.dao.Site2SiteVpnConnectionVO; import com.cloud.network.dao.Site2SiteVpnGatewayDao; import com.cloud.network.dao.Site2SiteVpnGatewayVO; +import com.cloud.network.nsx.NsxVpnGatewayResult; import com.cloud.network.vpc.Vpc; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; -import com.cloud.network.nsx.NsxVpnGatewayResult; +import com.cloud.utils.db.DB; +import com.cloud.utils.db.GlobalLock; import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.net.Ip; import org.apache.cloudstack.NsxAnswer; @@ -37,14 +40,15 @@ import org.apache.cloudstack.agent.api.DeleteNsxNatRuleCommand; import org.apache.cloudstack.agent.api.DeleteNsxSegmentCommand; import org.apache.cloudstack.agent.api.DeleteNsxTier1GatewayCommand; -import org.apache.cloudstack.utils.NsxControllerUtils; import org.apache.cloudstack.resourcedetail.UserIpAddressDetailVO; import org.apache.cloudstack.resourcedetail.dao.UserIpAddressDetailsDao; +import org.apache.cloudstack.utils.NsxControllerUtils; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.junit.MockitoJUnitRunner; @@ -62,8 +66,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -81,6 +85,8 @@ public class NsxServiceImplTest { private Site2SiteVpnGatewayDao site2SiteVpnGatewayDao; @Mock private UserIpAddressDetailsDao userIpAddressDetailsDao; + @Mock + private AlertManager alertManager; NsxServiceImpl nsxService; AutoCloseable closeable; @@ -98,6 +104,7 @@ public void setup() { nsxService.site2SiteVpnConnectionDao = site2SiteVpnConnectionDao; nsxService.site2SiteVpnGatewayDao = site2SiteVpnGatewayDao; nsxService.userIpAddressDetailsDao = userIpAddressDetailsDao; + nsxService.alertManager = alertManager; } @After @@ -220,7 +227,7 @@ public void testPollVpnConnectionStatusTransitionsUp() { NsxServiceImpl service = new NsxServiceImpl() { @Override - public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + public String getVpnConnectionStatus(Vpc vpc, long connectionId) { return "UP"; } @@ -246,7 +253,7 @@ public void testPollVpnConnectionStatusTransitionsDown() { NsxServiceImpl service = new NsxServiceImpl() { @Override - public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + public String getVpnConnectionStatus(Vpc vpc, long connectionId) { return VPN_SESSION_STATUS_DOWN; } @@ -272,7 +279,7 @@ public void testPollVpnConnectionStatusKeepsPendingConnectionWhenSessionIsNotFou NsxServiceImpl service = new NsxServiceImpl() { @Override - public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + public String getVpnConnectionStatus(Vpc vpc, long connectionId) { return VPN_SESSION_STATUS_NOT_FOUND; } @@ -298,7 +305,7 @@ public void testPollVpnConnectionStatusMarksMissingConnectedSessionAsError() { NsxServiceImpl service = new NsxServiceImpl() { @Override - public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + public String getVpnConnectionStatus(Vpc vpc, long connectionId) { return VPN_SESSION_STATUS_NOT_FOUND; } @@ -324,7 +331,7 @@ public void testPollVpnConnectionStatusDoesNotTransitionOnQueryFailure() { NsxServiceImpl service = new NsxServiceImpl() { @Override - public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + public String getVpnConnectionStatus(Vpc vpc, long connectionId) { throw new CloudRuntimeException("NSX unavailable"); } @@ -342,6 +349,37 @@ protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, assertTrue(!transitioned.get()); } + @Test + public void testPollVpnConnectionStatusAlertsAfterThreeConsecutiveQueryFailures() { + Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); + VpcVO vpc = mock(VpcVO.class); + when(connection.getId()).thenReturn(11L); + when(connection.getUuid()).thenReturn("vpn-connection-uuid"); + when(connection.getState()).thenReturn(Site2SiteVpnConnection.State.Connected); + when(vpc.getName()).thenReturn("production-vpc"); + when(vpc.getZoneId()).thenReturn(7L); + + NsxServiceImpl service = new NsxServiceImpl() { + @Override + public String getVpnConnectionStatus(Vpc vpc, long connectionId) { + throw new CloudRuntimeException("NSX unavailable"); + } + }; + service.alertManager = alertManager; + + service.pollVpnConnectionStatus(connection, vpc); + service.pollVpnConnectionStatus(connection, vpc); + verify(alertManager, never()).sendAlert(any(), anyLong(), any(), any(), any()); + + service.pollVpnConnectionStatus(connection, vpc); + verify(alertManager).sendAlert(eq(AlertManager.AlertType.ALERT_TYPE_DOMAIN_ROUTER), eq(7L), + eq(null), any(String.class), any(String.class)); + + // The counter resets after the alert, so the next failure starts a new sequence. + service.pollVpnConnectionStatus(connection, vpc); + verify(alertManager, times(1)).sendAlert(any(), anyLong(), any(), any(), any()); + } + @Test public void testTransitionVpnConnectionStateIgnoresStaleStatusObservation() { Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); @@ -361,6 +399,13 @@ public void testTransitionVpnConnectionStateIgnoresStaleStatusObservation() { verify(site2SiteVpnConnectionDao).releaseFromLockTable(11L); } + @Test + public void testVpnStatusTransitionDefinesDatabaseContextForConnectionLock() throws NoSuchMethodException { + assertTrue(NsxServiceImpl.class.getDeclaredMethod("transitionVpnConnectionState", + Site2SiteVpnConnectionVO.class, VpcVO.class, Site2SiteVpnConnection.State.class, + Site2SiteVpnConnection.State.class).isAnnotationPresent(DB.class)); + } + @Test public void testVpnStatusPollerSkipsUnmarkedGatewayRegardlessOfCurrentOffering() { Site2SiteVpnConnectionVO connection = mockPollableVpnConnection(); @@ -369,7 +414,7 @@ public void testVpnStatusPollerSkipsUnmarkedGatewayRegardlessOfCurrentOffering() when(vpcDao.findById(gateway.getVpcId())).thenReturn(vpc); NsxServiceImpl service = Mockito.spy(nsxService); - service.new VpnStatusPollTask().runInContext(); + runVpnStatusPollTaskWithLockAcquired(service); verify(service, never()).pollVpnConnectionStatus(connection, vpc); } @@ -385,14 +430,14 @@ public void testVpnStatusPollerUsesPersistedOwnershipAfterOfferingChanges() { NsxServiceImpl service = Mockito.spy(nsxService); doNothing().when(service).pollVpnConnectionStatus(connection, vpc); - service.new VpnStatusPollTask().runInContext(); + runVpnStatusPollTaskWithLockAcquired(service); verify(service).pollVpnConnectionStatus(connection, vpc); } @Test public void testVpnStatusPollerQueriesOnlyPollableStates() { - nsxService.new VpnStatusPollTask().runInContext(); + runVpnStatusPollTaskWithLockAcquired(nsxService); verify(site2SiteVpnConnectionDao).listByStates( Site2SiteVpnConnection.State.Pending, @@ -402,6 +447,21 @@ public void testVpnStatusPollerQueriesOnlyPollableStates() { verify(site2SiteVpnConnectionDao, never()).listAll(); } + @Test + public void testVpnStatusPollerSkipsScanWhenAnotherManagementServerOwnsGlobalLock() { + GlobalLock scanLock = mock(GlobalLock.class); + try (MockedStatic globalLocks = Mockito.mockStatic(GlobalLock.class)) { + globalLocks.when(() -> GlobalLock.getInternLock(NsxServiceImpl.VPN_STATUS_POLL_LOCK_NAME)).thenReturn(scanLock); + when(scanLock.lock(NsxServiceImpl.VPN_STATUS_POLL_LOCK_TIMEOUT)).thenReturn(false); + + nsxService.new VpnStatusPollTask().runInContext(); + + verify(site2SiteVpnConnectionDao, never()).listByStates(any(Site2SiteVpnConnection.State[].class)); + verify(scanLock, never()).unlock(); + verify(scanLock).releaseRef(); + } + } + @Test public void testVpnStatusPollerCanRestartInSameJvm() throws Exception { ScheduledExecutorService firstExecutor = mock(ScheduledExecutorService.class); @@ -450,4 +510,17 @@ private Site2SiteVpnGatewayVO mockVpnGatewayForPoller(Site2SiteVpnConnectionVO c when(site2SiteVpnGatewayDao.findById(connection.getVpnGatewayId())).thenReturn(gateway); return gateway; } + + private void runVpnStatusPollTaskWithLockAcquired(NsxServiceImpl service) { + GlobalLock scanLock = mock(GlobalLock.class); + try (MockedStatic globalLocks = Mockito.mockStatic(GlobalLock.class)) { + globalLocks.when(() -> GlobalLock.getInternLock(NsxServiceImpl.VPN_STATUS_POLL_LOCK_NAME)).thenReturn(scanLock); + when(scanLock.lock(NsxServiceImpl.VPN_STATUS_POLL_LOCK_TIMEOUT)).thenReturn(true); + + service.new VpnStatusPollTask().runInContext(); + + verify(scanLock).unlock(); + verify(scanLock).releaseRef(); + } + } } diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxVpnGatewayOwnershipTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxVpnGatewayOwnershipTest.java deleted file mode 100644 index 4161e545c18d..000000000000 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxVpnGatewayOwnershipTest.java +++ /dev/null @@ -1,147 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. -package org.apache.cloudstack.service; - -import com.cloud.network.IpAddress; -import com.cloud.network.Network; -import com.cloud.network.dao.FirewallRulesDao; -import com.cloud.network.dao.IPAddressDao; -import com.cloud.network.dao.IPAddressVO; -import com.cloud.network.dao.LoadBalancerDao; -import com.cloud.network.nsx.NsxVpnGatewayResult; -import com.cloud.network.rules.dao.PortForwardingRulesDao; -import com.cloud.network.vpc.VpcManager; -import com.cloud.network.vpc.VpcVO; -import com.cloud.utils.exception.CloudRuntimeException; -import com.cloud.utils.net.Ip; -import org.apache.cloudstack.resourcedetail.UserIpAddressDetailVO; -import org.apache.cloudstack.resourcedetail.dao.UserIpAddressDetailsDao; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; - -import java.util.List; - -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@RunWith(MockitoJUnitRunner.class) -public class NsxVpnGatewayOwnershipTest { - - private static final long VPC_ID = 9L; - private static final long IP_ADDRESS_ID = 20L; - private static final String IP_ADDRESS = "203.0.113.20"; - - @Mock - private NsxServiceImpl nsxService; - @Mock - private VpcManager vpcManager; - @Mock - private IPAddressDao ipAddressDao; - @Mock - private UserIpAddressDetailsDao userIpAddressDetailsDao; - @Mock - private FirewallRulesDao firewallRulesDao; - @Mock - private PortForwardingRulesDao portForwardingRulesDao; - @Mock - private LoadBalancerDao loadBalancerDao; - @Mock - private VpcVO vpc; - @Mock - private IpAddress requestedIp; - @Mock - private IPAddressVO ipAddress; - - private NsxElement element; - - @Before - public void setUp() { - element = new NsxElement(); - element.nsxService = nsxService; - element.vpcManager = vpcManager; - element.ipAddressDao = ipAddressDao; - element.userIpAddressDetailsDao = userIpAddressDetailsDao; - element.firewallRulesDao = firewallRulesDao; - element.portForwardingRulesDao = portForwardingRulesDao; - element.loadBalancerDao = loadBalancerDao; - - when(vpc.getId()).thenReturn(VPC_ID); - when(vpc.getName()).thenReturn("vpc"); - when(vpcManager.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, Network.Provider.Nsx)) - .thenReturn(true); - when(requestedIp.getId()).thenReturn(IP_ADDRESS_ID); - when(ipAddressDao.findById(IP_ADDRESS_ID)).thenReturn(ipAddress); - when(ipAddress.getId()).thenReturn(IP_ADDRESS_ID); - when(ipAddress.getVpcId()).thenReturn(VPC_ID); - when(ipAddress.readyToUse()).thenReturn(true); - when(ipAddress.getAddress()).thenReturn(new Ip(IP_ADDRESS)); - when(firewallRulesDao.listByIpAndNotRevoked(IP_ADDRESS_ID)).thenReturn(List.of()); - when(portForwardingRulesDao.listByIpAndNotRevoked(IP_ADDRESS_ID)).thenReturn(List.of()); - when(loadBalancerDao.listByIpAddress(IP_ADDRESS_ID)).thenReturn(List.of()); - } - - @Test - public void testExistingOwnershipMarkerIsNotOverwrittenOrRemovedOnFailure() { - UserIpAddressDetailVO existingDetail = new UserIpAddressDetailVO( - IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL, "true", false); - when(userIpAddressDetailsDao.findDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL)) - .thenReturn(existingDetail); - when(nsxService.createVpnGateway(vpc, IP_ADDRESS)).thenReturn(new NsxVpnGatewayResult(false, false)); - - Assert.assertThrows(CloudRuntimeException.class, - () -> element.acquireVpnGatewayIp(vpc, requestedIp)); - - verify(userIpAddressDetailsDao, never()).addDetail(anyLong(), anyString(), anyString(), anyBoolean()); - verify(userIpAddressDetailsDao, never()).removeDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL); - } - - @Test - public void testTemporaryOwnershipMarkerIsRemovedAfterUnambiguousFailure() { - when(userIpAddressDetailsDao.findDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL)) - .thenReturn(null); - when(nsxService.createVpnGateway(vpc, IP_ADDRESS)).thenReturn(new NsxVpnGatewayResult(false, false)); - - Assert.assertThrows(CloudRuntimeException.class, - () -> element.acquireVpnGatewayIp(vpc, requestedIp)); - - verify(userIpAddressDetailsDao).addDetail( - IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL, "false", false); - verify(userIpAddressDetailsDao).removeDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL); - } - - @Test - public void testTemporaryOwnershipMarkerIsRetainedAfterAmbiguousFailure() { - when(userIpAddressDetailsDao.findDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL)) - .thenReturn(null); - when(nsxService.createVpnGateway(vpc, IP_ADDRESS)).thenReturn(new NsxVpnGatewayResult(false, true)); - - Assert.assertThrows(CloudRuntimeException.class, - () -> element.acquireVpnGatewayIp(vpc, requestedIp)); - - verify(userIpAddressDetailsDao).addDetail( - IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL, "false", false); - verify(userIpAddressDetailsDao, never()).removeDetail(IP_ADDRESS_ID, NsxElement.NSX_VPN_GATEWAY_IP_DETAIL); - } -} diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxControllerUtilsTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxControllerUtilsTest.java index 9168cd04960b..b1a4d0a45fd4 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxControllerUtilsTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxControllerUtilsTest.java @@ -17,6 +17,7 @@ package org.apache.cloudstack.utils; import com.cloud.agent.AgentManager; +import com.cloud.agent.api.Answer; import com.cloud.exception.InvalidParameterValueException; import com.cloud.network.dao.NsxProviderDao; import com.cloud.network.element.NsxProviderVO; @@ -94,6 +95,18 @@ public void testSendCommandRejectsFailedNsxAnswer() { nsxControllerUtils.sendNsxCommand(cmd, zoneId); } + @Test + public void testSendCommandRejectsGenericFailedAnswer() { + NsxCommand cmd = Mockito.mock(NsxCommand.class); + Answer answer = new Answer(cmd, false, "transport failure"); + Mockito.when(agentMgr.easySend(nsxProviderHostId, cmd)).thenReturn(answer); + + InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class, + () -> nsxControllerUtils.sendNsxCommand(cmd, zoneId)); + + Assert.assertEquals("Failed API call to NSX controller", exception.getMessage()); + } + @Test public void testSendCommandForResultPreservesFailedNsxAnswer() { NsxCommand cmd = Mockito.mock(NsxCommand.class); diff --git a/server/src/main/java/com/cloud/api/ApiResponseHelper.java b/server/src/main/java/com/cloud/api/ApiResponseHelper.java index 2510dc0a88b4..282e5e149bbe 100644 --- a/server/src/main/java/com/cloud/api/ApiResponseHelper.java +++ b/server/src/main/java/com/cloud/api/ApiResponseHelper.java @@ -337,6 +337,7 @@ import com.cloud.network.Site2SiteCustomerGateway; import com.cloud.network.Site2SiteVpnConnection; import com.cloud.network.Site2SiteVpnGateway; +import com.cloud.network.Site2SiteVpnTunnelInterface; import com.cloud.network.VirtualRouterProvider; import com.cloud.network.VpnUser; import com.cloud.network.VpnUserVO; @@ -4036,6 +4037,14 @@ public Site2SiteVpnConnectionResponse createSite2SiteVpnConnectionResponse(Site2 } } + Site2SiteVpnTunnelInterface tunnelInterface = + site2SiteVpnManager.getSite2SiteVpnTunnelInterface(result); + if (tunnelInterface != null) { + response.setLocalVtiIp(tunnelInterface.getLocalIp()); + response.setPeerVtiIp(tunnelInterface.getPeerIp()); + response.setVtiPrefixLength(tunnelInterface.getPrefixLength()); + } + populateAccount(response, result.getAccountId()); populateDomain(response, result.getDomainId()); diff --git a/server/src/main/java/com/cloud/network/vpn/RemoteAccessVpnManagerImpl.java b/server/src/main/java/com/cloud/network/vpn/RemoteAccessVpnManagerImpl.java index d7d650f27781..81c6a3b20ba0 100644 --- a/server/src/main/java/com/cloud/network/vpn/RemoteAccessVpnManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpn/RemoteAccessVpnManagerImpl.java @@ -315,7 +315,7 @@ private RemoteAccessVPNServiceProvider requireRemoteAccessVpnServiceProvider(Lon return provider; } - void validateIpAddressForVpnServiceOnNetwork(Network network, IPAddressVO ipAddress) { + private void validateIpAddressForVpnServiceOnNetwork(Network network, IPAddressVO ipAddress) { Long networkId = network.getId(); requireRemoteAccessVpnServiceProvider(networkId, null); if (_networkMgr.isProviderSupportServiceInNetwork(networkId, Service.Vpn, Network.Provider.VirtualRouter)) { @@ -335,7 +335,7 @@ void validateIpAddressForVpnServiceOnNetwork(Network network, IPAddressVO ipAddr } } - void validateIpAddressForVpnServiceOnVpc(Vpc vpc, IPAddressVO ipAddress) { + private void validateIpAddressForVpnServiceOnVpc(Vpc vpc, IPAddressVO ipAddress) { Long vpcId = vpc.getId(); requireRemoteAccessVpnServiceProvider(null, vpcId); if (vpcManager.isProviderSupportServiceInVpc(vpcId, Service.Vpn, Network.Provider.VPCVirtualRouter)) { diff --git a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManager.java b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManager.java index 9cf604f85070..86165a812726 100644 --- a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManager.java +++ b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManager.java @@ -20,6 +20,8 @@ import java.util.Set; import com.cloud.network.Site2SiteCustomerGateway; +import com.cloud.network.Site2SiteVpnConnection; +import com.cloud.network.Site2SiteVpnTunnelInterface; import com.cloud.network.dao.Site2SiteVpnConnectionVO; import com.cloud.vm.DomainRouterVO; @@ -39,4 +41,6 @@ public interface Site2SiteVpnManager extends Site2SiteVpnService { boolean deleteCustomerGatewayByAccount(long accountId); void reconnectDisconnectedVpnByVpc(Long vpcId); + + Site2SiteVpnTunnelInterface getSite2SiteVpnTunnelInterface(Site2SiteVpnConnection connection); } diff --git a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java index ed1862371641..d971a1d5ffd6 100644 --- a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java @@ -69,6 +69,7 @@ import com.cloud.network.Site2SiteVpnConnection; import com.cloud.network.Site2SiteVpnConnection.State; import com.cloud.network.Site2SiteVpnGateway; +import com.cloud.network.Site2SiteVpnTunnelInterface; import com.cloud.network.dao.IPAddressDao; import com.cloud.network.dao.IPAddressVO; import com.cloud.network.dao.Site2SiteCustomerGatewayDao; @@ -297,6 +298,19 @@ private Site2SiteVpnServiceProvider getVpnServiceProviderForGateway(Site2SiteVpn return null; } + @Override + public Site2SiteVpnTunnelInterface getSite2SiteVpnTunnelInterface(Site2SiteVpnConnection connection) { + if (connection == null) { + return null; + } + Site2SiteVpnGateway gateway = _vpnGatewayDao.findById(connection.getVpnGatewayId()); + if (gateway == null) { + return null; + } + Site2SiteVpnServiceProvider provider = getVpnServiceProviderForGateway(gateway); + return provider == null ? null : provider.getSite2SiteVpnTunnelInterface(connection); + } + private IPAddressVO getIpAddressIdForVpn(Vpc vpc, Site2SiteVpnServiceProvider provider, IPAddressVO requestedIp) { IpAddress providerIp = provider.acquireVpnGatewayIp(vpc, requestedIp); if (providerIp != null) { diff --git a/server/src/test/java/com/cloud/api/ApiResponseHelperTest.java b/server/src/test/java/com/cloud/api/ApiResponseHelperTest.java index c0c019f6dbd8..a679c7c7937f 100644 --- a/server/src/test/java/com/cloud/api/ApiResponseHelperTest.java +++ b/server/src/test/java/com/cloud/api/ApiResponseHelperTest.java @@ -52,6 +52,7 @@ import org.apache.cloudstack.api.response.IpQuarantineResponse; import org.apache.cloudstack.api.response.NicSecondaryIpResponse; import org.apache.cloudstack.api.response.ResourceIconResponse; +import org.apache.cloudstack.api.response.Site2SiteVpnConnectionResponse; import org.apache.cloudstack.api.response.TemplateResponse; import org.apache.cloudstack.api.response.UnmanagedInstanceResponse; import org.apache.cloudstack.api.response.UsageRecordResponse; @@ -67,6 +68,8 @@ import com.cloud.network.Networks; import com.cloud.network.PhysicalNetworkTrafficType; import com.cloud.network.PublicIpQuarantine; +import com.cloud.network.Site2SiteVpnConnection; +import com.cloud.network.Site2SiteVpnTunnelInterface; import com.cloud.network.as.AutoScaleVmGroup; import com.cloud.network.as.AutoScaleVmGroupVO; import com.cloud.network.as.AutoScaleVmProfileVO; @@ -78,6 +81,7 @@ import com.cloud.network.dao.NetworkVO; import com.cloud.network.dao.PhysicalNetworkVO; import com.cloud.network.dao.PhysicalNetworkTrafficTypeVO; +import com.cloud.network.vpn.Site2SiteVpnManager; import com.cloud.resource.icon.ResourceIconVO; import com.cloud.server.ResourceIcon; import com.cloud.server.ResourceIconManager; @@ -137,6 +141,9 @@ public class ApiResponseHelperTest { @Mock ResourceIconManager resourceIconManager; + @Mock + Site2SiteVpnManager site2SiteVpnManager; + @Mock private ConsoleSessionVO consoleSessionMock; @Mock @@ -254,6 +261,24 @@ public void setResponseIpAddressTestIpv4() { assertTrue(response.getIpAddr().equals("ipv4")); } + @Test + public void createSite2SiteVpnConnectionResponseIncludesTunnelInterfaceConfiguration() { + Site2SiteVpnConnection connection = Mockito.mock(Site2SiteVpnConnection.class); + when(connection.getUuid()).thenReturn("connection-uuid"); + when(connection.getState()).thenReturn(Site2SiteVpnConnection.State.Connected); + when(site2SiteVpnManager.getSite2SiteVpnTunnelInterface(connection)).thenReturn( + new Site2SiteVpnTunnelInterface("169.254.64.21", "169.254.64.22", 30)); + + try (MockedStatic ignored = Mockito.mockStatic(ApiDBUtils.class)) { + Site2SiteVpnConnectionResponse response = + apiResponseHelper.createSite2SiteVpnConnectionResponse(connection); + + assertEquals("169.254.64.21", response.getLocalVtiIp()); + assertEquals("169.254.64.22", response.getPeerVtiIp()); + assertEquals(Integer.valueOf(30), response.getVtiPrefixLength()); + } + } + private void setResult(NicSecondaryIp result, String ipv4, String ipv6) { when(result.getIp4Address()).thenReturn(ipv4); when(result.getIp6Address()).thenReturn(ipv6); diff --git a/server/src/test/java/com/cloud/network/vpn/RemoteAccessVpnManagerImplTest.java b/server/src/test/java/com/cloud/network/vpn/RemoteAccessVpnManagerImplTest.java index 1a39a29e9f53..169c836b8756 100644 --- a/server/src/test/java/com/cloud/network/vpn/RemoteAccessVpnManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpn/RemoteAccessVpnManagerImplTest.java @@ -15,16 +15,20 @@ package com.cloud.network.vpn; import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.NetworkRuleConflictException; import com.cloud.exception.ResourceUnavailableException; import com.cloud.network.Network; +import com.cloud.network.PublicIpAddress; +import com.cloud.network.dao.IPAddressDao; import com.cloud.network.dao.IPAddressVO; import com.cloud.network.dao.RemoteAccessVpnDao; import com.cloud.network.dao.RemoteAccessVpnVO; import com.cloud.network.element.NetworkElement; import com.cloud.network.element.RemoteAccessVPNServiceProvider; import com.cloud.network.rules.FirewallManager; -import com.cloud.network.vpc.Vpc; import com.cloud.network.vpc.VpcManager; +import com.cloud.network.vpc.VpcVO; +import com.cloud.network.vpc.dao.VpcDao; import com.cloud.user.Account; import com.cloud.user.AccountManager; import com.cloud.utils.exception.CloudRuntimeException; @@ -45,8 +49,8 @@ @RunWith(MockitoJUnitRunner.class) public class RemoteAccessVpnManagerImplTest extends TestCase { - private static final long NETWORK_ID = 11L; private static final long VPC_ID = 12L; + private static final long PUBLIC_IP_ID = 31L; Class expectedException = InvalidParameterValueException.class; Class cloudRuntimeException = CloudRuntimeException.class; @@ -67,50 +71,44 @@ private RemoteAccessVpnManagerImpl managerWithProvider(RemoteAccessVPNServicePro } @Test - public void validateVpcRemoteAccessVpnAcceptsMappedVpcVirtualRouterProvider() { + public void createRemoteAccessVpnRejectsVpcWithoutMappedProviderBeforePersistence() throws NetworkRuleConflictException { RemoteAccessVPNServiceProvider provider = mockProvider(Network.Provider.VPCVirtualRouter); RemoteAccessVpnManagerImpl manager = managerWithProvider(provider); - Vpc vpc = Mockito.mock(Vpc.class); - IPAddressVO ipAddress = Mockito.mock(IPAddressVO.class); - Mockito.when(vpc.getId()).thenReturn(VPC_ID); - Mockito.when(manager.vpcManager.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, - Network.Provider.VPCVirtualRouter)).thenReturn(true); - Mockito.when(manager.vpcManager.isProviderSupportServiceInVpc(VPC_ID, Network.Service.SourceNat, - Network.Provider.VPCVirtualRouter)).thenReturn(true); - Mockito.when(ipAddress.isSourceNat()).thenReturn(true); - - manager.validateIpAddressForVpnServiceOnVpc(vpc, ipAddress); - } + manager._remoteAccessVpnDao = Mockito.mock(RemoteAccessVpnDao.class); + manager._ipAddressDao = Mockito.mock(IPAddressDao.class); + manager._accountMgr = Mockito.mock(AccountManager.class); + manager._vpcDao = Mockito.mock(VpcDao.class); - @Test - public void validateVpcRemoteAccessVpnRejectsProviderWithoutRemoteAccessImplementationBeforePersistence() { - RemoteAccessVPNServiceProvider provider = mockProvider(Network.Provider.VPCVirtualRouter); - RemoteAccessVpnManagerImpl manager = managerWithProvider(provider); - Vpc vpc = Mockito.mock(Vpc.class); + PublicIpAddress publicIpAddress = Mockito.mock(PublicIpAddress.class); IPAddressVO ipAddress = Mockito.mock(IPAddressVO.class); + VpcVO vpc = Mockito.mock(VpcVO.class); + Account caller = Mockito.mock(Account.class); + CallContext context = Mockito.mock(CallContext.class); + + Mockito.when(context.getCallingAccount()).thenReturn(caller); + Mockito.when(manager._networkMgr.getPublicIpAddress(PUBLIC_IP_ID)).thenReturn(publicIpAddress); + Mockito.when(publicIpAddress.readyToUse()).thenReturn(true); + Mockito.when(manager._ipAddressDao.acquireInLockTable(PUBLIC_IP_ID)).thenReturn(ipAddress); + Mockito.when(ipAddress.getAssociatedWithNetworkId()).thenReturn(null); + Mockito.when(ipAddress.getVpcId()).thenReturn(VPC_ID); + Mockito.when(ipAddress.isSourceNat()).thenReturn(true); + Mockito.when(manager._vpcDao.findById(VPC_ID)).thenReturn(vpc); Mockito.when(vpc.getId()).thenReturn(VPC_ID); + Mockito.when(vpc.getCidr()).thenReturn("10.2.0.0/24"); Mockito.when(manager.vpcManager.isProviderSupportServiceInVpc(VPC_ID, Network.Service.Vpn, Network.Provider.VPCVirtualRouter)).thenReturn(false); - InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class, - () -> manager.validateIpAddressForVpnServiceOnVpc(vpc, ipAddress)); - Assert.assertTrue(exception.getMessage().contains("does not implement the Remote Access VPN service")); - } + try (MockedStatic callContext = Mockito.mockStatic(CallContext.class)) { + callContext.when(CallContext::current).thenReturn(context); - @Test - public void validateNetworkRemoteAccessVpnAcceptsMappedVirtualRouterProvider() { - RemoteAccessVPNServiceProvider provider = mockProvider(Network.Provider.VirtualRouter); - RemoteAccessVpnManagerImpl manager = managerWithProvider(provider); - Network network = Mockito.mock(Network.class); - IPAddressVO ipAddress = Mockito.mock(IPAddressVO.class); - Mockito.when(network.getId()).thenReturn(NETWORK_ID); - Mockito.when(manager._networkMgr.isProviderSupportServiceInNetwork(NETWORK_ID, Network.Service.Vpn, - Network.Provider.VirtualRouter)).thenReturn(true); - Mockito.when(manager._networkMgr.isProviderSupportServiceInNetwork(NETWORK_ID, Network.Service.SourceNat, - Network.Provider.VirtualRouter)).thenReturn(true); - Mockito.when(ipAddress.isSourceNat()).thenReturn(true); + InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class, + () -> manager.createRemoteAccessVpn(PUBLIC_IP_ID, "10.1.2.1-10.1.2.8", false, null)); - manager.validateIpAddressForVpnServiceOnNetwork(network, ipAddress); + Assert.assertTrue(exception.getMessage().contains("does not implement the Remote Access VPN service")); + } + Mockito.verify(manager._remoteAccessVpnDao, Mockito.never()).persist(Mockito.any()); + Mockito.verify(manager.vpcManager, Mockito.never()).configStaticNatForVpcVr(Mockito.any(), Mockito.any()); + Mockito.verify(manager._ipAddressDao).releaseFromLockTable(PUBLIC_IP_ID); } @Test diff --git a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java index 15b5156ff805..1d33ff3ebd14 100644 --- a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java @@ -730,6 +730,23 @@ public void testDeleteVpnGatewaySuccess() { verify(_vpnGatewayDao).remove(VPN_GATEWAY_ID); } + @Test + public void testDeleteVpnGatewayProviderFailureKeepsGatewayRow() { + DeleteVpnGatewayCmd cmd = mock(DeleteVpnGatewayCmd.class); + when(cmd.getId()).thenReturn(VPN_GATEWAY_ID); + when(_vpnGatewayDao.findById(VPN_GATEWAY_ID)).thenReturn(vpnGateway); + when(_vpnConnectionDao.listByVpnGatewayId(VPN_GATEWAY_ID)).thenReturn(new ArrayList<>()); + Site2SiteVpnServiceProvider provider = mockNsxProvider(); + when(provider.ownsVpnGateway(vpnGateway)).thenReturn(true); + Mockito.doThrow(new CloudRuntimeException("NSX Tier-1 lookup failed")) + .when(provider).releaseVpnGatewayIp(vpnGateway); + + assertThrows(CloudRuntimeException.class, () -> site2SiteVpnManager.deleteVpnGateway(cmd)); + + verify(provider).releaseVpnGatewayIp(vpnGateway); + verify(_vpnGatewayDao, never()).remove(VPN_GATEWAY_ID); + } + @Test(expected = InvalidParameterValueException.class) public void testDeleteVpnGatewayWithConnections() { DeleteVpnGatewayCmd cmd = mock(DeleteVpnGatewayCmd.class); diff --git a/server/src/test/java/com/cloud/vpc/MockSite2SiteVpnManagerImpl.java b/server/src/test/java/com/cloud/vpc/MockSite2SiteVpnManagerImpl.java index af6c1c599aab..fa7b72f2f603 100644 --- a/server/src/test/java/com/cloud/vpc/MockSite2SiteVpnManagerImpl.java +++ b/server/src/test/java/com/cloud/vpc/MockSite2SiteVpnManagerImpl.java @@ -21,6 +21,7 @@ import com.cloud.network.Site2SiteCustomerGateway; import com.cloud.network.Site2SiteVpnConnection; import com.cloud.network.Site2SiteVpnGateway; +import com.cloud.network.Site2SiteVpnTunnelInterface; import com.cloud.network.dao.Site2SiteVpnConnectionVO; import com.cloud.network.vpn.Site2SiteVpnManager; import com.cloud.utils.Pair; @@ -48,6 +49,11 @@ @Component public class MockSite2SiteVpnManagerImpl extends ManagerBase implements Site2SiteVpnManager { + @Override + public Site2SiteVpnTunnelInterface getSite2SiteVpnTunnelInterface(Site2SiteVpnConnection connection) { + return null; + } + /* (non-Javadoc) * @see com.cloud.network.vpn.Site2SiteVpnService#createVpnGateway(org.apache.cloudstack.api.commands.CreateVpnGatewayCmd) */ diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 775de26103a0..0c0840eb2130 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1618,6 +1618,7 @@ "label.local.storage.enabled.system.vms": "Enable local storage for system VMs", "label.localstorageenabled": "Enable local storage for User Instances", "label.localstorageenabledforsystemvm": "Enable local storage for system VMs", +"label.localvtiip": "Provider VTI IP address", "label.locked": "Locked", "label.login": "Login", "label.loginfo": "Log file information", @@ -1996,6 +1997,7 @@ "label.peerserviceport": "Service Port", "label.peerstate": "Peer State", "label.peerstate.lastupdated": "Peer State Updated Time", +"label.peervtiip": "Peer VTI IP address", "label.pending.jobs": "Pending Jobs", "label.pendingjobscount": "Number Of pending jobs", "label.per.account": "Per Account", @@ -3004,6 +3006,7 @@ "label.vswitch.type.nexusdvs": "Cisco Nexus 1000v distributed virtual switch", "label.vswitch.type.vmwaredvs": "VMware vNetwork distributed virtual switch", "label.vswitch.type.vmwaresvs": "VMware vNetwork standard virtual switch", +"label.vtiprefixlength": "VTI prefix length", "label.vxlan": "VXLAN", "label.waiting": "Waiting", "label.warn": "Warn", diff --git a/ui/src/config/section/network.js b/ui/src/config/section/network.js index 30608a82a98c..371737547a65 100644 --- a/ui/src/config/section/network.js +++ b/ui/src/config/section/network.js @@ -1104,7 +1104,7 @@ export default { icon: 'sync-outlined', permission: ['listVpnConnections'], columns: ['publicip', 'state', 'gateway', 'ipsecpsk', 'ikepolicy', 'esppolicy'], - details: ['publicip', 'gateway', 'passive', 'cidrlist', 'ipsecpsk', 'ikepolicy', 'esppolicy', 'ikelifetime', 'ikeversion', 'esplifetime', 'dpd', 'splitconnections', 'forceencap', 'created'], + details: ['publicip', 'gateway', 'localvtiip', 'peervtiip', 'vtiprefixlength', 'passive', 'cidrlist', 'ipsecpsk', 'ikepolicy', 'esppolicy', 'ikelifetime', 'ikeversion', 'esplifetime', 'dpd', 'splitconnections', 'forceencap', 'created'], actions: [ { api: 'createVpnConnection',