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/3] 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/3] 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/3] 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));