From 6af3d424e0ba1672df044fd6964ce10a80a1bc18 Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:51:55 +0100 Subject: [PATCH 01/30] Preserve installed CloudStack source before VPN integration --- PendingReleaseNotes | 29 + .../element/Site2SiteVpnServiceProvider.java | 22 + .../apache/cloudstack/api/ApiConstants.java | 1 + .../com/cloud/network/vpc/VpcManager.java | 10 + .../cloud/network/dao/NsxVrfGatewayDao.java | 40 ++ .../network/dao/NsxVrfGatewayDaoImpl.java | 98 +++ .../network/element/NsxVrfGatewayVO.java | 203 ++++++ ...spring-engine-schema-core-daos-context.xml | 1 + .../META-INF/db/schema-42210to42300.sql | 24 + .../cluster/KubernetesClusterManagerImpl.java | 7 +- .../KubernetesClusterActionWorker.java | 4 + ...esClusterResourceModifierActionWorker.java | 10 +- .../KubernetesClusterStartWorker.java | 7 +- .../KubernetesClusterManagerImplTest.java | 8 +- ...usterResourceModifierActionWorkerTest.java | 31 + .../KubernetesClusterStartWorkerTest.java | 73 ++ .../api/CreateNsxTier1GatewayCommand.java | 24 + .../api/CreateNsxVpnConnectionCommand.java | 171 +++++ .../agent/api/CreateNsxVpnGatewayCommand.java | 63 ++ .../api/DeleteNsxVpnConnectionCommand.java | 64 ++ .../agent/api/DeleteNsxVpnGatewayCommand.java | 57 ++ .../api/GetNsxVpnSessionStatusCommand.java | 64 ++ .../api/command/AddNsxVrfGatewayCmd.java | 88 +++ .../api/command/AssignNsxVrfGatewayCmd.java | 82 +++ .../api/command/DeleteNsxVrfGatewayCmd.java | 64 ++ .../api/command/ListNsxVrfGatewaysCmd.java | 84 +++ .../api/command/ReleaseNsxVrfGatewayCmd.java | 63 ++ .../api/response/NsxVrfGatewayResponse.java | 148 ++++ .../cloudstack/resource/NsxResource.java | 107 ++- .../cloudstack/service/NsxApiClient.java | 673 +++++++++++++++++- .../apache/cloudstack/service/NsxElement.java | 196 ++++- .../service/NsxProviderService.java | 18 + .../service/NsxProviderServiceImpl.java | 242 +++++++ .../cloudstack/service/NsxServiceImpl.java | 342 ++++++++- .../cloudstack/utils/NsxControllerUtils.java | 44 ++ .../apache/cloudstack/utils/NsxHelper.java | 38 + .../cloudstack/utils/NsxVpnCryptoUtils.java | 181 +++++ .../cloudstack/service/NsxElementTest.java | 235 ++++++ .../service/NsxProviderServiceImplTest.java | 206 ++++++ .../service/NsxServiceImplTest.java | 382 +++++++++- .../cloudstack/utils/NsxHelperTest.java | 88 +++ .../utils/NsxVpnCryptoUtilsTest.java | 174 +++++ .../network/NetworkMigrationManagerImpl.java | 1 + ...VpcVirtualNetworkApplianceManagerImpl.java | 5 +- .../com/cloud/network/vpc/VpcManagerImpl.java | 39 +- .../network/vpn/Site2SiteVpnManagerImpl.java | 73 +- .../cloud/network/vpc/VpcManagerImplTest.java | 152 ++++ .../vpn/Site2SiteVpnManagerImplTest.java | 119 ++++ ui/public/locales/en.json | 3 + ui/src/views/network/VpcTiersTab.vue | 111 ++- 50 files changed, 4935 insertions(+), 34 deletions(-) create mode 100644 engine/schema/src/main/java/com/cloud/network/dao/NsxVrfGatewayDao.java create mode 100644 engine/schema/src/main/java/com/cloud/network/dao/NsxVrfGatewayDaoImpl.java create mode 100644 engine/schema/src/main/java/com/cloud/network/element/NsxVrfGatewayVO.java create mode 100644 plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorkerTest.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/api/command/AddNsxVrfGatewayCmd.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/AssignNsxVrfGatewayCmd.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/DeleteNsxVrfGatewayCmd.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/ListNsxVrfGatewaysCmd.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/ReleaseNsxVrfGatewayCmd.java create mode 100644 plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/response/NsxVrfGatewayResponse.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/PendingReleaseNotes b/PendingReleaseNotes index 9670b6e7c13a..2c88335c3b7c 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -39,3 +39,32 @@ example.ver.1 > example.ver.2: which can now be attached to Instances. This is to prevent the Secondary Storage to grow to enormous sizes as Linux Distributions keep growing in size while a stripped down Linux should fit on a 2.88MB floppy. + +4.22.1.0 > 4.23.0.0: + * VPC tiers created without an explicit network ACL now default to the + default_deny ACL (matching private gateway behavior) instead of being + persisted without any ACL, when the network offering supports the + NetworkACL service. Automation that created tiers without the aclid + parameter and relied on the previous implicit egress-allow behavior of + VR-based tiers should pass an explicit aclid. + + * Kubernetes clusters can no longer be created on VPC tiers that have no + network ACL attached or that use the immutable default_deny ACL; the API + now fails fast at validation instead of failing partway through cluster + provisioning. Attach an ACL that allows the required traffic (for example + default_allow or a custom ACL) to the tier before creating the cluster. + + * createVpnGateway now validates up front that the VPC's offering provides + the Vpn service through an available Site-to-Site VPN provider, returning + a clear error instead of "Cannot found source nat ip". + + * NSX: Site-to-Site VPN is now implemented natively for NSX NAT-mode VPCs. + The Vpn service of the seeded "VPC offering with NSX - NAT Mode" is backed + by route-based IPsec sessions terminating on the VPC's Tier-1 gateway. A + dedicated public IP is acquired for the VPN local endpoint when the VPN + gateway is created (or the IP passed via the ipaddressid parameter is + used) and released when the gateway is deleted. Remote peers must use + routed (VTI-style) IPsec configuration; policy-based peers requiring + strict subnet selectors are not supported. Some cryptographic options + permitted by CloudStack are rejected by NSX (3des, md5, aes192, + modp6144 and above, IKE lifetime below 21600 seconds) with a clear error. 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..32055ba2aa81 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,33 @@ package com.cloud.network.element; import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.IpAddress; 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 { boolean startSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUnavailableException; boolean stopSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUnavailableException; + + /** + * 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) { + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index ac6acdf42516..99c5808a464a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -923,6 +923,7 @@ public class ApiConstants { // NSX public static final String EDGE_CLUSTER = "edgecluster"; public static final String TIER0_GATEWAY = "tier0gateway"; + public static final String PARENT_TIER0_GATEWAY = "parenttier0gateway"; public static final String TRANSPORT_ZONE = "transportzone"; // Tungsten-Fabric diff --git a/engine/components-api/src/main/java/com/cloud/network/vpc/VpcManager.java b/engine/components-api/src/main/java/com/cloud/network/vpc/VpcManager.java index 792a3a6b397f..65419cbdf101 100644 --- a/engine/components-api/src/main/java/com/cloud/network/vpc/VpcManager.java +++ b/engine/components-api/src/main/java/com/cloud/network/vpc/VpcManager.java @@ -57,6 +57,16 @@ public interface VpcManager { true, ConfigKey.Scope.Global, null); + ConfigKey VpcTierDefaultNetworkACL = new ConfigKey<>(String.class, + "vpc.tier.default.network.acl", + ConfigKey.CATEGORY_NETWORK, + "default_allow", + "Network ACL assigned to a VPC tier created without an ACL, either default_allow or default_deny. " + + "Tiers created on a network offering used by the Kubernetes service always get default_allow, " + + "as Kubernetes clusters cannot be deployed on a tier using the default deny ACL", + true, + ConfigKey.Scope.Zone, + null); /** * Returns all the Guest networks that are part of VPC diff --git a/engine/schema/src/main/java/com/cloud/network/dao/NsxVrfGatewayDao.java b/engine/schema/src/main/java/com/cloud/network/dao/NsxVrfGatewayDao.java new file mode 100644 index 000000000000..9ccb95473e23 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/network/dao/NsxVrfGatewayDao.java @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.network.dao; + +import java.util.List; + +import com.cloud.network.element.NsxVrfGatewayVO; +import com.cloud.utils.db.GenericDao; + +public interface NsxVrfGatewayDao extends GenericDao { + + NsxVrfGatewayVO findByUuid(String uuid); + + NsxVrfGatewayVO findByZoneAndTier0Name(long zoneId, String tier0Name); + + /** The gateway claimed by this exact account, if any. */ + NsxVrfGatewayVO findByAccount(long zoneId, long accountId); + + /** The gateway claimed by this exact domain, if any — no ancestor walking. */ + NsxVrfGatewayVO findByDomain(long zoneId, long domainId); + + List listByZone(long zoneId); + + /** Pool members in this zone that no tenant has claimed. */ + List listUnclaimed(long zoneId); +} diff --git a/engine/schema/src/main/java/com/cloud/network/dao/NsxVrfGatewayDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/NsxVrfGatewayDaoImpl.java new file mode 100644 index 000000000000..c28154cd8da5 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/network/dao/NsxVrfGatewayDaoImpl.java @@ -0,0 +1,98 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.network.dao; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.cloud.network.element.NsxVrfGatewayVO; +import com.cloud.utils.db.DB; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; + +@Component +@DB() +public class NsxVrfGatewayDaoImpl extends GenericDaoBase implements NsxVrfGatewayDao { + + private final SearchBuilder allFieldsSearch; + private final SearchBuilder unclaimedSearch; + + public NsxVrfGatewayDaoImpl() { + super(); + + allFieldsSearch = createSearchBuilder(); + allFieldsSearch.and("uuid", allFieldsSearch.entity().getUuid(), SearchCriteria.Op.EQ); + allFieldsSearch.and("zone_id", allFieldsSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + allFieldsSearch.and("nsx_tier0_name", allFieldsSearch.entity().getNsxTier0Name(), SearchCriteria.Op.EQ); + allFieldsSearch.and("account_id", allFieldsSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + allFieldsSearch.and("domain_id", allFieldsSearch.entity().getDomainId(), SearchCriteria.Op.EQ); + allFieldsSearch.done(); + + unclaimedSearch = createSearchBuilder(); + unclaimedSearch.and("zone_id", unclaimedSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + unclaimedSearch.and("account_id", unclaimedSearch.entity().getAccountId(), SearchCriteria.Op.NULL); + unclaimedSearch.and("domain_id", unclaimedSearch.entity().getDomainId(), SearchCriteria.Op.NULL); + unclaimedSearch.done(); + } + + @Override + public NsxVrfGatewayVO findByUuid(String uuid) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("uuid", uuid); + return findOneBy(sc); + } + + @Override + public NsxVrfGatewayVO findByZoneAndTier0Name(long zoneId, String tier0Name) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("zone_id", zoneId); + sc.setParameters("nsx_tier0_name", tier0Name); + return findOneBy(sc); + } + + @Override + public NsxVrfGatewayVO findByAccount(long zoneId, long accountId) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("zone_id", zoneId); + sc.setParameters("account_id", accountId); + return findOneBy(sc); + } + + @Override + public NsxVrfGatewayVO findByDomain(long zoneId, long domainId) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("zone_id", zoneId); + sc.setParameters("domain_id", domainId); + return findOneBy(sc); + } + + @Override + public List listByZone(long zoneId) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("zone_id", zoneId); + return listBy(sc); + } + + @Override + public List listUnclaimed(long zoneId) { + SearchCriteria sc = unclaimedSearch.create(); + sc.setParameters("zone_id", zoneId); + return listBy(sc); + } +} diff --git a/engine/schema/src/main/java/com/cloud/network/element/NsxVrfGatewayVO.java b/engine/schema/src/main/java/com/cloud/network/element/NsxVrfGatewayVO.java new file mode 100644 index 000000000000..cda2adf5b2e0 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/network/element/NsxVrfGatewayVO.java @@ -0,0 +1,203 @@ +// 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.element; + +import java.util.Date; +import java.util.UUID; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.cloudstack.api.InternalIdentity; + +/** + * A VRF (or dedicated) Tier-0 gateway that an operator has staged in NSX and registered + * with CloudStack, so that a tenant's Tier-1 gateways attach to it instead of to the + * zone-wide Tier-0 recorded in {@code nsx_providers}. + * + * CloudStack never creates or deletes the gateway in NSX: the uplink interfaces and BGP + * peerings a Tier-0 needs are provisioned with the operator's physical network and cannot + * be automated from here. A row is a claim on something that already exists. + * + * A row with neither {@code accountId} nor {@code domainId} is an unclaimed pool member. + */ +@Entity +@Table(name = "nsx_vrf_gateways") +public class NsxVrfGatewayVO implements InternalIdentity { + + /** How a staged gateway is matched to a tenant. */ + public enum Scope { + ACCOUNT, DOMAIN + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "zone_id") + private long zoneId; + + @Column(name = "nsx_tier0_name") + private String nsxTier0Name; + + @Column(name = "edge_cluster") + private String edgeCluster; + + @Column(name = "parent_tier0") + private String parentTier0; + + @Column(name = "scope") + private String scope; + + @Column(name = "domain_id") + private Long domainId; + + @Column(name = "account_id") + private Long accountId; + + @Column(name = "public_vlan_db_id") + private Long publicVlanDbId; + + @Column(name = "created") + private Date created; + + @Column(name = "removed") + private Date removed; + + public NsxVrfGatewayVO() { + this.uuid = UUID.randomUUID().toString(); + } + + public NsxVrfGatewayVO(long zoneId, String nsxTier0Name, String edgeCluster, String parentTier0) { + this(); + this.zoneId = zoneId; + this.nsxTier0Name = nsxTier0Name; + this.edgeCluster = edgeCluster; + this.parentTier0 = parentTier0; + this.created = new Date(); + } + + @Override + public long getId() { + return id; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public long getZoneId() { + return zoneId; + } + + public void setZoneId(long zoneId) { + this.zoneId = zoneId; + } + + public String getNsxTier0Name() { + return nsxTier0Name; + } + + public void setNsxTier0Name(String nsxTier0Name) { + this.nsxTier0Name = nsxTier0Name; + } + + public String getEdgeCluster() { + return edgeCluster; + } + + public void setEdgeCluster(String edgeCluster) { + this.edgeCluster = edgeCluster; + } + + public String getParentTier0() { + return parentTier0; + } + + public void setParentTier0(String parentTier0) { + this.parentTier0 = parentTier0; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public Long getDomainId() { + return domainId; + } + + public void setDomainId(Long domainId) { + this.domainId = domainId; + } + + public Long getAccountId() { + return accountId; + } + + public void setAccountId(Long accountId) { + this.accountId = accountId; + } + + public Long getPublicVlanDbId() { + return publicVlanDbId; + } + + public void setPublicVlanDbId(Long publicVlanDbId) { + this.publicVlanDbId = publicVlanDbId; + } + + public Date getCreated() { + return created; + } + + public Date getRemoved() { + return removed; + } + + /** True when no tenant has claimed this gateway yet. */ + public boolean isUnclaimed() { + return accountId == null && domainId == null; + } + + // NOTE: do not add a helper here that clears the assignment by writing the fields + // directly. Entities are CGLIB-enhanced and GenericDaoBase builds its UPDATE from + // setter calls intercepted by UpdateBuilder, so direct field writes persist nothing + // and the update silently succeeds having changed no columns. Callers must use the + // setters (see NsxProviderServiceImpl.releaseNsxVrfGateway). + + @Override + public String toString() { + return String.format("NsxVrfGateway {id: %d, uuid: %s, tier0: %s, edgeCluster: %s, scope: %s}", + id, uuid, nsxTier0Name, edgeCluster, scope); + } +} diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml index 932db538f30b..09e64f2ec509 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml @@ -140,6 +140,7 @@ + diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index ab5ac7b2b875..5cfdd0207197 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -646,3 +646,27 @@ CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.backup_schedule', 'isolated', 'TINYI UPDATE `cloud`.`configuration` SET `value`=CONCAT(`value`, ', backupValidationCommandTimeout, backupValidationScreenshotWait, backupValidationBootTimeout') WHERE `name`='user.vm.readonly.details' AND `value` IS NOT NULL; + +-- NSX per-tenant VRF gateways. +-- One row per operator-staged VRF (or dedicated) Tier-0. A row with no account_id and +-- no domain_id is an unclaimed member of the pool; assigning it to a tenant fills them. +-- CloudStack never creates or deletes the gateway in NSX, it only records and claims. +CREATE TABLE IF NOT EXISTS `cloud`.`nsx_vrf_gateways` ( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40), + `zone_id` bigint unsigned NOT NULL COMMENT 'Zone ID', + `nsx_tier0_name` varchar(255) NOT NULL COMMENT 'VRF Tier-0, or a dedicated Tier-0, as named in NSX', + `edge_cluster` varchar(255) NOT NULL COMMENT 'Edge cluster this Tier-0 lives on; may differ from the zone default', + `parent_tier0` varchar(255) COMMENT 'Parent Tier-0 for a VRF gateway; NULL for a dedicated Tier-0', + `scope` varchar(16) COMMENT 'ACCOUNT or DOMAIN; NULL while unclaimed', + `domain_id` bigint unsigned COMMENT 'Owning domain when scope = DOMAIN', + `account_id` bigint unsigned COMMENT 'Owning account when scope = ACCOUNT', + `public_vlan_db_id` bigint unsigned COMMENT 'Dedicated public IP range this Tier-0 advertises', + `created` datetime NOT NULL COMMENT 'date created', + `removed` datetime COMMENT 'date removed if not null', + PRIMARY KEY (`id`), + CONSTRAINT `fk_nsx_vrf_gateways__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE, + INDEX `i_nsx_vrf_gateways__zone_id`(`zone_id`), + INDEX `i_nsx_vrf_gateways__account_id`(`account_id`), + INDEX `i_nsx_vrf_gateways__domain_id`(`domain_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/KubernetesClusterManagerImpl.java b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/KubernetesClusterManagerImpl.java index aa5ddf0cd006..7b479878dbf8 100644 --- a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/KubernetesClusterManagerImpl.java +++ b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/KubernetesClusterManagerImpl.java @@ -608,10 +608,11 @@ private void validateIsolatedNetwork(Network network, int clusterTotalNodeCount) } protected void validateVpcTier(Network network) { - if (Network.State.Allocated.equals(network.getState())) { // Allocated networks won't have IP and rules - return; + Long networkAclId = network.getNetworkACLId(); + if (networkAclId == null) { + throw new InvalidParameterValueException(String.format("Network ID: %s can not be used for Kubernetes cluster as it does not have a network ACL attached. Attach a network ACL allowing the required traffic to the VPC tier and retry", network.getUuid())); } - if (network.getNetworkACLId() == NetworkACL.DEFAULT_DENY) { + if (networkAclId == NetworkACL.DEFAULT_DENY) { throw new InvalidParameterValueException(String.format("Network ID: %s can not be used for Kubernetes cluster as it uses default deny ACL", network.getUuid())); } } diff --git a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterActionWorker.java b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterActionWorker.java index 4fcf6fa7686d..64252455c01c 100644 --- a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterActionWorker.java +++ b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterActionWorker.java @@ -681,6 +681,10 @@ protected void updateLoginUserDetails(List clusterVMs) { protected boolean stateTransitTo(long kubernetesClusterId, KubernetesCluster.Event e) { KubernetesClusterVO kubernetesCluster = kubernetesClusterDao.findById(kubernetesClusterId); + if (kubernetesCluster == null) { + logger.warn("Cannot transit state on event {} for the Kubernetes cluster with ID: {} as it no longer exists", e, kubernetesClusterId); + return false; + } try { return _stateMachine.transitTo(kubernetesCluster, e, null, kubernetesClusterDao); } catch (NoTransitionException nte) { diff --git a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java index a1b2294bcd4d..600cb2d2bbfa 100644 --- a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java +++ b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java @@ -752,7 +752,11 @@ protected void setupKubernetesClusterIsolatedNetworkRules(IpAddress publicIp, Ne } protected void createVpcTierAclRules(Network network) throws ManagementServerException { - if (network.getNetworkACLId() == NetworkACL.DEFAULT_ALLOW) { + Long networkAclId = network.getNetworkACLId(); + if (networkAclId == null) { + throw new ManagementServerException(String.format("Failed to provision ACL rules for the Kubernetes cluster : %s as VPC tier %s does not have a network ACL attached", kubernetesCluster.getName(), network.getName())); + } + if (networkAclId == NetworkACL.DEFAULT_ALLOW) { return; } // ACL rule for API access for control node VMs @@ -781,7 +785,9 @@ protected void createVpcTierAclRules(Network network) throws ManagementServerExc } protected void removeVpcTierAclRules(Network network) throws ManagementServerException { - if (network.getNetworkACLId() == NetworkACL.DEFAULT_ALLOW) { + Long networkAclId = network.getNetworkACLId(); + if (networkAclId == null || networkAclId == NetworkACL.DEFAULT_ALLOW) { + // no ACL attached or default allow: no CKS-provisioned ACL rules can exist, nothing to remove return; } // ACL rule for API access for control node VMs diff --git a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java index 308fc07223de..53b23e37e88f 100644 --- a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java +++ b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java @@ -646,6 +646,8 @@ protected void setupKubernetesEtcdNetworkRules(List etcdVms, Network net try { if (Objects.isNull(network.getVpcId())) { provisionFirewallRules(publicIp, owner, etcdStartPort, etcdStartPort); + } else if (network.getNetworkACLId() == null) { + throw new ManagementServerException(String.format("Failed to provision ACL rules for etcd access for the Kubernetes cluster : %s as VPC tier %s does not have a network ACL attached", kubernetesCluster.getName(), network.getName())); } else if (network.getNetworkACLId() != NetworkACL.DEFAULT_ALLOW) { try { provisionVpcTierAllowPortACLRule(network, ETCD_NODE_CLIENT_REQUEST_PORT, ETCD_NODE_CLIENT_REQUEST_PORT); @@ -733,8 +735,11 @@ private boolean isKubernetesClusterDashboardServiceRunning(final boolean onCreat return false; } - private void updateKubernetesClusterEntryEndpoint() { + protected void updateKubernetesClusterEntryEndpoint() { KubernetesClusterVO kubernetesClusterVO = kubernetesClusterDao.findById(kubernetesCluster.getId()); + if (kubernetesClusterVO == null) { + throw new CloudRuntimeException(String.format("Failed to update endpoint of the Kubernetes cluster : %s as the cluster no longer exists, it may have been removed while the operation was in progress", kubernetesCluster.getName())); + } kubernetesClusterVO.setEndpoint(String.format("https://%s:%d/", publicIpAddress, CLUSTER_API_PORT)); kubernetesClusterDao.update(kubernetesCluster.getId(), kubernetesClusterVO); } diff --git a/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/KubernetesClusterManagerImplTest.java b/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/KubernetesClusterManagerImplTest.java index 1fab5420c3c3..fa75980c5ee1 100644 --- a/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/KubernetesClusterManagerImplTest.java +++ b/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/KubernetesClusterManagerImplTest.java @@ -122,17 +122,16 @@ public class KubernetesClusterManagerImplTest { @InjectMocks KubernetesClusterManagerImpl kubernetesClusterManager; - @Test - public void testValidateVpcTierAllocated() { + @Test(expected = InvalidParameterValueException.class) + public void testValidateVpcTierNoAclAttached() { Network network = Mockito.mock(Network.class); - Mockito.when(network.getState()).thenReturn(Network.State.Allocated); + Mockito.when(network.getNetworkACLId()).thenReturn(null); kubernetesClusterManager.validateVpcTier(network); } @Test(expected = InvalidParameterValueException.class) public void testValidateVpcTierDefaultDenyRule() { Network network = Mockito.mock(Network.class); - Mockito.when(network.getState()).thenReturn(Network.State.Implemented); Mockito.when(network.getNetworkACLId()).thenReturn(NetworkACL.DEFAULT_DENY); kubernetesClusterManager.validateVpcTier(network); } @@ -140,7 +139,6 @@ public void testValidateVpcTierDefaultDenyRule() { @Test public void testValidateVpcTierValid() { Network network = Mockito.mock(Network.class); - Mockito.when(network.getState()).thenReturn(Network.State.Implemented); Mockito.when(network.getNetworkACLId()).thenReturn(NetworkACL.DEFAULT_ALLOW); kubernetesClusterManager.validateVpcTier(network); } diff --git a/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorkerTest.java b/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorkerTest.java index c220a3468afb..dac708713a76 100644 --- a/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorkerTest.java +++ b/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorkerTest.java @@ -17,12 +17,15 @@ package com.cloud.kubernetes.cluster.actionworkers; +import com.cloud.exception.ManagementServerException; import com.cloud.kubernetes.cluster.KubernetesCluster; import com.cloud.kubernetes.cluster.KubernetesClusterManagerImpl; import com.cloud.kubernetes.cluster.dao.KubernetesClusterDao; import com.cloud.kubernetes.cluster.dao.KubernetesClusterDetailsDao; import com.cloud.kubernetes.cluster.dao.KubernetesClusterVmMapDao; import com.cloud.kubernetes.version.dao.KubernetesSupportedVersionDao; +import com.cloud.network.Network; +import com.cloud.network.vpc.NetworkACL; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -135,4 +138,32 @@ public void getKubernetesClusterNodeNamePrefixTestNormalizedPrefixShouldNotStart Mockito.when(kubernetesClusterMock.getName()).thenReturn(originalPrefix); Assert.assertEquals(expectedPrefix, kubernetesClusterResourceModifierActionWorker.getKubernetesClusterNodeNamePrefix()); } + + @Test(expected = ManagementServerException.class) + public void createVpcTierAclRulesTestThrowsWhenTierHasNoAclAttached() throws ManagementServerException { + Network network = Mockito.mock(Network.class); + Mockito.when(network.getNetworkACLId()).thenReturn(null); + kubernetesClusterResourceModifierActionWorker.createVpcTierAclRules(network); + } + + @Test + public void createVpcTierAclRulesTestSkipsProvisioningForDefaultAllowAcl() throws ManagementServerException { + Network network = Mockito.mock(Network.class); + Mockito.when(network.getNetworkACLId()).thenReturn(NetworkACL.DEFAULT_ALLOW); + kubernetesClusterResourceModifierActionWorker.createVpcTierAclRules(network); + } + + @Test + public void removeVpcTierAclRulesTestSkipsQuietlyWhenTierHasNoAclAttached() throws ManagementServerException { + Network network = Mockito.mock(Network.class); + Mockito.when(network.getNetworkACLId()).thenReturn(null); + kubernetesClusterResourceModifierActionWorker.removeVpcTierAclRules(network); + } + + @Test + public void removeVpcTierAclRulesTestSkipsForDefaultAllowAcl() throws ManagementServerException { + Network network = Mockito.mock(Network.class); + Mockito.when(network.getNetworkACLId()).thenReturn(NetworkACL.DEFAULT_ALLOW); + kubernetesClusterResourceModifierActionWorker.removeVpcTierAclRules(network); + } } diff --git a/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorkerTest.java b/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorkerTest.java new file mode 100644 index 000000000000..156d9b6c2928 --- /dev/null +++ b/plugins/integrations/kubernetes-service/src/test/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorkerTest.java @@ -0,0 +1,73 @@ +// 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.kubernetes.cluster.actionworkers; + +import com.cloud.kubernetes.cluster.KubernetesCluster; +import com.cloud.kubernetes.cluster.KubernetesClusterManagerImpl; +import com.cloud.kubernetes.cluster.KubernetesClusterVO; +import com.cloud.kubernetes.cluster.dao.KubernetesClusterDao; +import com.cloud.utils.exception.CloudRuntimeException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class KubernetesClusterStartWorkerTest { + + @Mock + private KubernetesClusterDao kubernetesClusterDaoMock; + + @Mock + private KubernetesClusterManagerImpl kubernetesClusterManagerMock; + + @Mock + private KubernetesCluster kubernetesClusterMock; + + private KubernetesClusterStartWorker kubernetesClusterStartWorker; + + @Before + public void setUp() { + kubernetesClusterManagerMock.kubernetesClusterDao = kubernetesClusterDaoMock; + + kubernetesClusterStartWorker = new KubernetesClusterStartWorker(kubernetesClusterMock, kubernetesClusterManagerMock); + } + + @Test(expected = CloudRuntimeException.class) + public void updateKubernetesClusterEntryEndpointTestThrowsWhenClusterNoLongerExists() { + Mockito.when(kubernetesClusterMock.getId()).thenReturn(1L); + Mockito.when(kubernetesClusterDaoMock.findById(1L)).thenReturn(null); + + kubernetesClusterStartWorker.updateKubernetesClusterEntryEndpoint(); + } + + @Test + public void updateKubernetesClusterEntryEndpointTestUpdatesEndpoint() { + KubernetesClusterVO kubernetesClusterVO = Mockito.mock(KubernetesClusterVO.class); + Mockito.when(kubernetesClusterMock.getId()).thenReturn(1L); + Mockito.when(kubernetesClusterDaoMock.findById(1L)).thenReturn(kubernetesClusterVO); + kubernetesClusterStartWorker.publicIpAddress = "10.1.1.1"; + + kubernetesClusterStartWorker.updateKubernetesClusterEntryEndpoint(); + + Mockito.verify(kubernetesClusterVO).setEndpoint(String.format("https://10.1.1.1:%d/", KubernetesClusterActionWorker.CLUSTER_API_PORT)); + Mockito.verify(kubernetesClusterDaoMock).update(1L, kubernetesClusterVO); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxTier1GatewayCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxTier1GatewayCommand.java index 90e4b3a25bdd..aad7c3b3ad31 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxTier1GatewayCommand.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxTier1GatewayCommand.java @@ -24,6 +24,14 @@ public class CreateNsxTier1GatewayCommand extends NsxCommand { private String networkResourceName; private boolean isResourceVpc; private boolean sourceNatEnabled; + /** + * Tier-0 and edge cluster this gateway should be created under, resolved from the + * tenant's registered NSX VRF gateway. Both are null when the zone is not + * VRF-segregated, in which case the resource falls back to the zone-wide values it + * was configured with — which keeps every pre-VRF deployment byte-identical. + */ + private String tier0Gateway; + private String edgeCluster; public CreateNsxTier1GatewayCommand(long domainId, long accountId, long zoneId, Long networkResourceId, String networkResourceName, boolean isResourceVpc, @@ -51,6 +59,22 @@ public boolean isSourceNatEnabled() { return sourceNatEnabled; } + public String getTier0Gateway() { + return tier0Gateway; + } + + public void setTier0Gateway(String tier0Gateway) { + this.tier0Gateway = tier0Gateway; + } + + public String getEdgeCluster() { + return edgeCluster; + } + + public void setEdgeCluster(String edgeCluster) { + this.edgeCluster = edgeCluster; + } + @Override public boolean equals(Object o) { if (this == o) return true; 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/api/command/AddNsxVrfGatewayCmd.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/AddNsxVrfGatewayCmd.java new file mode 100644 index 000000000000..d1de4cdf0c00 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/AddNsxVrfGatewayCmd.java @@ -0,0 +1,88 @@ +// 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; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.NsxVrfGatewayResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.NsxProviderService; + +/** + * Registers a VRF, or dedicated, tier-0 gateway that the operator has already staged in + * NSX. CloudStack never creates the gateway itself: its uplink interfaces and BGP peerings + * are provisioned alongside the physical network and cannot be automated from here. + */ +@APICommand(name = AddNsxVrfGatewayCmd.APINAME, description = "Registers an existing NSX VRF or dedicated tier-0 gateway with CloudStack, so tenant tier-1 gateways can be attached to it", + responseObject = NsxVrfGatewayResponse.class, requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, since = "4.23.0") +public class AddNsxVrfGatewayCmd extends BaseCmd { + public static final String APINAME = "addNsxVrfGateway"; + + @Inject + NsxProviderService nsxProviderService; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, required = true, + description = "the ID of the zone the tier-0 gateway belongs to") + private Long zoneId; + + @Parameter(name = ApiConstants.TIER0_GATEWAY, type = CommandType.STRING, required = true, + description = "name of the VRF or dedicated tier-0 gateway as it exists in NSX") + private String tier0Gateway; + + @Parameter(name = ApiConstants.EDGE_CLUSTER, type = CommandType.STRING, required = true, + description = "name of the edge cluster the tier-0 gateway lives on; may differ from the zone default") + private String edgeCluster; + + @Parameter(name = ApiConstants.PARENT_TIER0_GATEWAY, type = CommandType.STRING, + description = "parent tier-0 gateway when registering a VRF gateway; omit for a dedicated tier-0") + private String parentTier0Gateway; + + public Long getZoneId() { + return zoneId; + } + + public String getTier0Gateway() { + return tier0Gateway; + } + + public String getEdgeCluster() { + return edgeCluster; + } + + public String getParentTier0Gateway() { + return parentTier0Gateway; + } + + @Override + public void execute() throws ServerApiException { + NsxVrfGatewayResponse response = nsxProviderService.addNsxVrfGateway(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/AssignNsxVrfGatewayCmd.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/AssignNsxVrfGatewayCmd.java new file mode 100644 index 000000000000..4d9876923582 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/AssignNsxVrfGatewayCmd.java @@ -0,0 +1,82 @@ +// 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; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AccountResponse; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.NsxVrfGatewayResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.NsxProviderService; + +/** + * Claims a staged tier-0 gateway for a tenant. Exactly one of accountid or domainid must + * be given: that choice is what {@code nsx.vrf.scope} matches against when a tenant's + * tier-1 gateways are created. + */ +@APICommand(name = AssignNsxVrfGatewayCmd.APINAME, description = "Assigns a registered NSX VRF gateway to an account or a domain", + responseObject = NsxVrfGatewayResponse.class, requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, since = "4.23.0") +public class AssignNsxVrfGatewayCmd extends BaseCmd { + public static final String APINAME = "assignNsxVrfGateway"; + + @Inject + NsxProviderService nsxProviderService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = NsxVrfGatewayResponse.class, + required = true, description = "the ID of the NSX VRF gateway to assign") + private Long id; + + @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, + description = "the ID of the account to assign the gateway to") + private Long accountId; + + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, + description = "the ID of the domain to assign the gateway to; every account in the domain, and in its " + + "sub-domains, resolves to this gateway unless it has one of its own") + private Long domainId; + + public Long getId() { + return id; + } + + public Long getAccountId() { + return accountId; + } + + public Long getDomainId() { + return domainId; + } + + @Override + public void execute() throws ServerApiException { + NsxVrfGatewayResponse response = nsxProviderService.assignNsxVrfGateway(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/DeleteNsxVrfGatewayCmd.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/DeleteNsxVrfGatewayCmd.java new file mode 100644 index 000000000000..075fe403fb38 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/DeleteNsxVrfGatewayCmd.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.api.command; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.NsxVrfGatewayResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.NsxProviderService; + +/** + * Deregisters a tier-0 gateway from CloudStack. The gateway itself is left untouched in + * NSX, along with its uplinks and BGP peerings — this only removes CloudStack's record. + */ +@APICommand(name = DeleteNsxVrfGatewayCmd.APINAME, description = "Removes CloudStack's registration of an NSX VRF gateway. The gateway itself is not deleted from NSX", + responseObject = SuccessResponse.class, requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, since = "4.23.0") +public class DeleteNsxVrfGatewayCmd extends BaseCmd { + public static final String APINAME = "deleteNsxVrfGateway"; + + @Inject + NsxProviderService nsxProviderService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = NsxVrfGatewayResponse.class, + required = true, description = "the ID of the NSX VRF gateway registration to remove") + private Long id; + + public Long getId() { + return id; + } + + @Override + public void execute() throws ServerApiException { + boolean result = nsxProviderService.deleteNsxVrfGateway(getId()); + SuccessResponse response = new SuccessResponse(getCommandName()); + response.setSuccess(result); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/ListNsxVrfGatewaysCmd.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/ListNsxVrfGatewaysCmd.java new file mode 100644 index 000000000000..d7cffb2902d2 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/ListNsxVrfGatewaysCmd.java @@ -0,0 +1,84 @@ +// 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; + +import java.util.List; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AccountResponse; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.NsxVrfGatewayResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.service.NsxProviderService; + +@APICommand(name = ListNsxVrfGatewaysCmd.APINAME, description = "Lists the NSX VRF gateways registered with CloudStack", + responseObject = NsxVrfGatewayResponse.class, requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, since = "4.23.0") +public class ListNsxVrfGatewaysCmd extends BaseListCmd { + public static final String APINAME = "listNsxVrfGateways"; + + @Inject + NsxProviderService nsxProviderService; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + description = "list gateways in this zone only") + private Long zoneId; + + @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, + description = "list the gateway assigned to this account only") + private Long accountId; + + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, + description = "list the gateway assigned to this domain only") + private Long domainId; + + @Parameter(name = ApiConstants.ALLOCATED_ONLY, type = CommandType.BOOLEAN, + description = "when true, list only gateways already assigned to a tenant; when false, only unassigned ones") + private Boolean allocatedOnly; + + public Long getZoneId() { + return zoneId; + } + + public Long getAccountId() { + return accountId; + } + + public Long getDomainId() { + return domainId; + } + + public Boolean getAllocatedOnly() { + return allocatedOnly; + } + + @Override + public void execute() throws ServerApiException { + List gateways = nsxProviderService.listNsxVrfGateways(this); + ListResponse response = new ListResponse<>(); + response.setResponses(gateways, gateways.size()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/ReleaseNsxVrfGatewayCmd.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/ReleaseNsxVrfGatewayCmd.java new file mode 100644 index 000000000000..c73765070339 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/command/ReleaseNsxVrfGatewayCmd.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.api.command; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.NsxVrfGatewayResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.NsxProviderService; + +/** + * Returns a tier-0 gateway to the unassigned pool. Refused while the tenant still has + * networks attached to it, since detaching a live tier-1 from its tier-0 is not something + * CloudStack can do without disrupting the tenant. + */ +@APICommand(name = ReleaseNsxVrfGatewayCmd.APINAME, description = "Releases an NSX VRF gateway from its account or domain, returning it to the pool", + responseObject = NsxVrfGatewayResponse.class, requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, since = "4.23.0") +public class ReleaseNsxVrfGatewayCmd extends BaseCmd { + public static final String APINAME = "releaseNsxVrfGateway"; + + @Inject + NsxProviderService nsxProviderService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = NsxVrfGatewayResponse.class, + required = true, description = "the ID of the NSX VRF gateway to release") + private Long id; + + public Long getId() { + return id; + } + + @Override + public void execute() throws ServerApiException { + NsxVrfGatewayResponse response = nsxProviderService.releaseNsxVrfGateway(getId()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/response/NsxVrfGatewayResponse.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/response/NsxVrfGatewayResponse.java new file mode 100644 index 000000000000..b2b9dfdee2c4 --- /dev/null +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/api/response/NsxVrfGatewayResponse.java @@ -0,0 +1,148 @@ +// 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.response; + +import com.cloud.network.element.NsxVrfGatewayVO; +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; + +@EntityReference(value = {NsxVrfGatewayVO.class}) +public class NsxVrfGatewayResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "ID of the NSX VRF gateway") + private String id; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "ID of the zone the VRF gateway belongs to") + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + @Param(description = "Name of the zone the VRF gateway belongs to") + private String zoneName; + + @SerializedName(ApiConstants.TIER0_GATEWAY) + @Param(description = "Name of the VRF, or dedicated, tier-0 gateway as it exists in NSX") + private String tier0Gateway; + + @SerializedName(ApiConstants.EDGE_CLUSTER) + @Param(description = "Name of the edge cluster this tier-0 gateway lives on") + private String edgeCluster; + + @SerializedName(ApiConstants.PARENT_TIER0_GATEWAY) + @Param(description = "Parent tier-0 gateway for a VRF gateway; empty for a dedicated tier-0") + private String parentTier0Gateway; + + @SerializedName(ApiConstants.SCOPE) + @Param(description = "Whether the gateway is assigned per ACCOUNT or per DOMAIN; empty while unassigned") + private String scope; + + @SerializedName(ApiConstants.ACCOUNT_ID) + @Param(description = "ID of the account the gateway is assigned to") + private String accountId; + + @SerializedName(ApiConstants.ACCOUNT) + @Param(description = "Name of the account the gateway is assigned to") + private String accountName; + + @SerializedName(ApiConstants.DOMAIN_ID) + @Param(description = "ID of the domain the gateway is assigned to") + private String domainId; + + @SerializedName(ApiConstants.DOMAIN) + @Param(description = "Name of the domain the gateway is assigned to") + private String domainName; + + @SerializedName(ApiConstants.ALLOCATED) + @Param(description = "Whether the gateway has been assigned to a tenant") + private boolean allocated; + + public NsxVrfGatewayResponse() { + setObjectName("nsxvrfgateway"); + } + + public void setId(String id) { + this.id = id; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public void setZoneName(String zoneName) { + this.zoneName = zoneName; + } + + public void setTier0Gateway(String tier0Gateway) { + this.tier0Gateway = tier0Gateway; + } + + public void setEdgeCluster(String edgeCluster) { + this.edgeCluster = edgeCluster; + } + + public void setParentTier0Gateway(String parentTier0Gateway) { + this.parentTier0Gateway = parentTier0Gateway; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public void setDomainId(String domainId) { + this.domainId = domainId; + } + + public void setDomainName(String domainName) { + this.domainName = domainName; + } + + public void setAllocated(boolean allocated) { + this.allocated = allocated; + } + + public String getId() { + return id; + } + + public String getTier0Gateway() { + return tier0Gateway; + } + + public String getEdgeCluster() { + return edgeCluster; + } + + public String getScope() { + return scope; + } + + public boolean isAllocated() { + return allocated; + } +} 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..13093cad0d71 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,15 +43,22 @@ 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.service.NsxApiClient; import org.apache.cloudstack.utils.NsxControllerUtils; +import org.apache.cloudstack.utils.NsxHelper; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -59,6 +67,7 @@ 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 { @@ -132,6 +141,16 @@ 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 { return Answer.createUnsupportedCommandAnswer(cmd); } @@ -304,8 +323,17 @@ private Answer executeRequest(CheckHealthCommand cmd) { private Answer executeRequest(CreateNsxTier1GatewayCommand cmd) { String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), cmd.getZoneId(), cmd.getNetworkResourceId(), cmd.isResourceVpc()); boolean sourceNatEnabled = cmd.isSourceNatEnabled(); + // The command carries a Tier-0 and edge cluster only when the tenant has an NSX + // VRF gateway registered. Otherwise fall back to the zone-wide values this + // resource was configured with, so non-VRF zones behave exactly as before. + String targetTier0 = StringUtils.defaultIfBlank(cmd.getTier0Gateway(), tier0Gateway); + String targetEdgeCluster = StringUtils.defaultIfBlank(cmd.getEdgeCluster(), edgeCluster); + if (!targetTier0.equals(tier0Gateway) || !targetEdgeCluster.equals(edgeCluster)) { + logger.debug("Creating tier 1 gateway {} on tier 0 {} (edge cluster {}) instead of the zone default {} ({})", + tier1GatewayName, targetTier0, targetEdgeCluster, tier0Gateway, edgeCluster); + } try { - nsxApiClient.createTier1Gateway(tier1GatewayName, tier0Gateway, edgeCluster, sourceNatEnabled); + nsxApiClient.createTier1Gateway(tier1GatewayName, targetTier0, targetEdgeCluster, sourceNatEnabled); return new NsxAnswer(cmd, true, ""); } catch (CloudRuntimeException e) { String msg = String.format("Cannot create tier 1 gateway %s (%s: %s): %s", tier1GatewayName, @@ -488,6 +516,83 @@ 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); + try { + nsxApiClient.createVpnService(tier1GatewayName, cmd.getLocalEndpointIp()); + } catch (Exception e) { + 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())); + return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + } + return new NsxAnswer(cmd, true, null); + } + + private NsxAnswer executeRequest(DeleteNsxVpnGatewayCommand cmd) { + String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), + cmd.getZoneId(), cmd.getVpcId(), true); + 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); + try { + // The requested VTI /30 is derived from the connection id and may collide with another + // session on the same tier-1: probe from it to the first free slot + Set inUseVtiIps = nsxApiClient.getRouteBasedVpnSessionLocalVtiIps(tier1GatewayName, cmd.getConnectionUuid()); + Pair vtiAddresses = NsxHelper.findFreeVpnVtiAddressPair(cmd.getVtiLocalIp(), inUseVtiIps); + 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()); + 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) { + 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); + 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(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..554620101418 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,16 @@ 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.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 +84,17 @@ 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.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 +111,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 +138,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 +173,25 @@ 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_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 +205,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, @@ -235,11 +289,82 @@ public boolean isNsxControllerActive() { public void createTier1NatRule(String tier1GatewayName, String natId, String natRuleId, String action, String translatedIp) { NatRules natRulesService = (NatRules) nsxService.apply(NatRules.class); - PolicyNatRule natPolicy = new PolicyNatRule.Builder() + PolicyNatRule.Builder natPolicyBuilder = new PolicyNatRule.Builder() .setAction(action) - .setTranslatedNetwork(translatedIp) + .setTranslatedNetwork(translatedIp); + if (NatAction.SNAT.name().equals(action)) { + // The source NAT rule matches any source, so it has to be evaluated after the rules that + // exempt specific traffic from it, such as the ones site-to-site VPN adds + natPolicyBuilder.setSequenceNumber(CATCH_ALL_NAT_SEQUENCE_NUMBER); + } + natRulesService.patch(tier1GatewayName, natId, natRuleId, natPolicyBuilder.build()); + } + + /** + * 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(); - natRulesService.patch(tier1GatewayName, natId, natRuleId, natPolicy); + natService.patch(tier1GatewayName, NatId.USER.name(), localEndpointNoSnatRuleName, localEndpointNoSnatRule); + } + + /** + * Existing tier-1 gateways carry a source NAT rule created without a sequence number, which leaves + * its precedence against the no-SNAT rules undefined. Push it behind them so traffic the VPN + * exempts, including the IKE traffic the gateway itself originates, is never translated. + */ + private void demoteCatchAllSourceNatRule(String tier1GatewayName) { + NatRules natRulesService = (NatRules) nsxService.apply(NatRules.class); + try { + List natRules = PagedFetcher.withPageFetcher( + cursor -> natRulesService.list(tier1GatewayName, NatId.USER.name(), cursor, false, null, 50L, false, null)) + .cursorExtractor(PolicyNatRuleListResult::getCursor) + .itemsExtractor(PolicyNatRuleListResult::getResults) + .itemsSetter((page, allItems) -> { + page.setResults(allItems); + page.setResultCount((long) allItems.size()); + }) + .fetchAll().getResults(); + for (PolicyNatRule natRule : natRules) { + if (!NatAction.SNAT.name().equals(natRule.getAction()) + || (natRule.getSequenceNumber() != null && natRule.getSequenceNumber() >= CATCH_ALL_NAT_SEQUENCE_NUMBER)) { + continue; + } + logger.debug("Moving the source NAT rule {} on tier-1 gateway {} behind the VPN no-SNAT rules", + natRule.getId(), tier1GatewayName); + // NSX validates the whole rule on a patch, so the existing definition has to be sent + // back with it rather than the sequence number on its own + PolicyNatRule.Builder demotedRule = new PolicyNatRule.Builder() + .setId(natRule.getId()) + .setDisplayName(natRule.getDisplayName()) + .setAction(natRule.getAction()) + .setTranslatedNetwork(natRule.getTranslatedNetwork()) + .setSourceNetwork(natRule.getSourceNetwork()) + .setDestinationNetwork(natRule.getDestinationNetwork()) + .setService(natRule.getService()) + .setFirewallMatch(natRule.getFirewallMatch()) + .setEnabled(natRule.getEnabled()) + .setSequenceNumber(CATCH_ALL_NAT_SEQUENCE_NUMBER); + natRulesService.patch(tier1GatewayName, NatId.USER.name(), natRule.getId(), demotedRule.build()); + } + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + logger.warn("Could not reorder the source NAT rules of tier-1 gateway {}, VPN traffic may be translated: {}", + tier1GatewayName, ae.getErrorMessage()); + } } public void createDhcpRelayConfig(String dhcpRelayConfigName, List addresses) { @@ -373,6 +498,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 +1362,543 @@ 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); + } + } + + /** + * 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) { + String vpnServiceName = getVpnServiceName(tier1GatewayName); + String localEndpointName = getVpnLocalEndpointName(vpnServiceName); + 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; + } + String localEndpointNoSnatRuleName = getVpnLocalEndpointNoSnatRuleName(vpnServiceName); + try { + NatRules natService = (NatRules) nsxService.apply(NatRules.class); + natService.delete(tier1GatewayName, NatId.USER.name(), localEndpointNoSnatRuleName); + } catch (NotFound e) { + logger.debug("The no-SNAT rule {} on tier-1 gateway {} no longer exists, skipping deletion", + localEndpointNoSnatRuleName, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to delete the no-SNAT rule %s of the NSX IPSec VPN service on tier-1 gateway %s, due to: %s", + localEndpointNoSnatRuleName, tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + try { + LocalEndpoints localEndpoints = (LocalEndpoints) nsxService.apply(LocalEndpoints.class); + localEndpoints.delete(tier1GatewayName, vpnServiceName, localEndpointName); + } catch (NotFound e) { + logger.debug("The local endpoint {} of the VPN service {} on tier-1 gateway {} no longer exists, skipping deletion", + localEndpointName, vpnServiceName, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to delete the local endpoint %s of the NSX IPSec VPN service %s on tier-1 gateway %s, due to: %s", + localEndpointName, vpnServiceName, tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + try { + IpsecVpnServices vpnServices = (IpsecVpnServices) nsxService.apply(IpsecVpnServices.class); + vpnServices.delete(tier1GatewayName, vpnServiceName); + } catch (NotFound e) { + logger.debug("The VPN service {} on tier-1 gateway {} no longer exists, skipping deletion", + vpnServiceName, tier1GatewayName); + } catch (Error error) { + ApiError ae = error.getData()._convertTo(ApiError.class); + String msg = String.format("Failed to delete NSX IPSec VPN service %s on tier-1 gateway %s, due to: %s", + vpnServiceName, tier1GatewayName, ae.getErrorMessage()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + } + + 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)) { + 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); + 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); + } + IpsecVpnTunnelProfiles espProfiles = (IpsecVpnTunnelProfiles) nsxService.apply(IpsecVpnTunnelProfiles.class); + espProfiles.patch(espProfileName, espProfileBuilder.build()); + + IpsecVpnDpdProfiles dpdProfiles = (IpsecVpnDpdProfiles) nsxService.apply(IpsecVpnDpdProfiles.class); + // 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 sessions = (Sessions) nsxService.apply(Sessions.class); + sessions.patch(tier1GatewayName, vpnServiceName, sessionName, session); + } 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); + } + } + + public void addVpnConnectionRoutes(String tier1GatewayName, String connectionUuid, List peerCidrs, + String vtiPeerIp, String vpcCidr) { + try { + 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) { + String vpnServiceName = getVpnServiceName(tier1GatewayName); + String sessionName = getVpnSessionName(connectionUuid); + // 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 + deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid)); + deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(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); + } + // The IKE/tunnel/DPD profiles are deliberately left in place: NSX marks deleted objects for + // deletion asynchronously, so recreating them under the same path on a connection restart + // fails until the reaper has run. They are reused (patched) when the session is recreated + // and swept with the rest of the VPN objects when the tier-1 gateway is removed. + } + + 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) { + 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); + } + 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); + } + 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); + } + } + + 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("")); + try { + IpsecVpnServices vpnServices = (IpsecVpnServices) nsxService.apply(IpsecVpnServices.class); + List services = vpnServices.list(tier1Id, null, false, null, null, false, null).getResults(); + if (CollectionUtils.isEmpty(services)) { + return; + } + Sessions sessions = (Sessions) nsxService.apply(Sessions.class); + LocalEndpoints localEndpoints = (LocalEndpoints) nsxService.apply(LocalEndpoints.class); + for (IPSecVpnService service : services) { + IPSecVpnSessionListResult sessionList = sessions.list(tier1Id, service.getId(), null, false, null, null, false, null); + if (CollectionUtils.isNotEmpty(sessionList.getResults())) { + String sessionNamePrefix = getVpnSessionName(""); + for (Structure result : sessionList.getResults()) { + 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())); + } + } + } + IPSecVpnLocalEndpointListResult localEndpointList = localEndpoints.list(tier1Id, service.getId(), null, false, null, null, false, null); + if (CollectionUtils.isNotEmpty(localEndpointList.getResults())) { + for (IPSecVpnLocalEndpoint localEndpoint : localEndpointList.getResults()) { + 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); + } + } + + /** + * 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 probe for a collision-free VTI /30 + */ + 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); + IPSecVpnSessionListResult sessionList = sessions.list(tier1GatewayName, vpnServiceName, null, false, null, null, false, null); + if (CollectionUtils.isEmpty(sessionList.getResults())) { + return vtiIps; + } + for (Structure result : sessionList.getResults()) { + 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..8ce690925932 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,13 +40,17 @@ 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.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; @@ -56,6 +60,10 @@ 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,6 +72,7 @@ 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; @@ -77,6 +86,7 @@ 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.dao.VpcOfferingServiceMapDao; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; @@ -108,14 +118,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 +151,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; @@ -172,6 +190,18 @@ public class NsxElement extends AdapterBase implements DhcpServiceProvider, Dns PhysicalNetworkServiceProviderDao pNtwkSvcProviderDao; @Inject FirewallRuleDetailsDao firewallRuleDetailsDao; + @Inject + IpAddressManager ipAddressManager; + @Inject + VpcService vpcService; + @Inject + Site2SiteVpnGatewayDao vpnGatewayDao; + @Inject + Site2SiteCustomerGatewayDao customerGatewayDao; + @Inject + UserIpAddressDetailsDao userIpAddressDetailsDao; + @Inject + FirewallRulesDao firewallRulesDao; protected Logger logger = LogManager.getLogger(getClass()); @@ -214,6 +244,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 +976,163 @@ public List> getCommands() { public boolean updateVpcSourceNatIp(Vpc vpc, IpAddress address) { return nsxService.updateVpcSourceNatIp(vpc, address); } + + protected boolean isVpnProvidedByNsx(Vpc vpc) { + return Objects.nonNull(vpc) && Objects.nonNull(vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId( + Network.Service.Vpn.getName(), Network.Provider.Nsx.getName(), vpc.getVpcOfferingId())); + } + + @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; + } + try { + nsxService.createVpnGateway(vpc, ip.getAddress().addr()); + } catch (Exception e) { + if (autoAcquired) { + releaseAutoAcquiredVpnGatewayIp(ip); + } + 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())) { + throw new InvalidParameterValueException(String.format( + "The requested IP %s is not associated to the VPC %s", requestedIp.getAddress().addr(), 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.countRulesByIpId(ip.getId()) > 0) { + 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); + return ipAddressDao.findById(allocatedIp.getId()); + } 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()); + } + } + } + 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) { + userIpAddressDetailsDao.removeDetail(ip.getId(), NSX_VPN_GATEWAY_IP_DETAIL); + ipAddressManager.disassociatePublicIpAddress(ip, CallContext.current().getCallingUserId(), CallContext.current().getCallingAccount()); + } + + @Override + public void releaseVpnGatewayIp(Site2SiteVpnGateway gateway) { + VpcVO vpc = vpcDao.findById(gateway.getVpcId()); + if (!isVpnProvidedByNsx(vpc)) { + return; + } + try { + nsxService.deleteVpnGateway(vpc); + } 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.nonNull(autoAcquiredDetail) && Boolean.parseBoolean(autoAcquiredDetail.getValue())) { + logger.debug("Releasing the auto-acquired VPN gateway IP {} of VPC {}", ip.getAddress().addr(), vpc.getName()); + releaseAutoAcquiredVpnGatewayIp(ip); + } + } + + @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 (!isVpnProvidedByNsx(vpc)) { + 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())); + } + NsxVpnCryptoUtils.validate(customerGateway.getIkePolicy(), customerGateway.getEspPolicy(), + customerGateway.getIkeVersion(), customerGateway.getIkeLifetime(), customerGateway.getEspLifetime(), + customerGateway.getIpsecPsk()); + 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 (!isVpnProvidedByNsx(vpc)) { + return true; + } + return nsxService.deleteVpnConnection(vpc, conn.getUuid()); + } } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxProviderService.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxProviderService.java index 47dfe04db3ef..4c48fefb2ed6 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxProviderService.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxProviderService.java @@ -20,7 +20,11 @@ import com.cloud.utils.component.PluggableService; import org.apache.cloudstack.api.BaseResponse; import org.apache.cloudstack.api.command.AddNsxControllerCmd; +import org.apache.cloudstack.api.command.AddNsxVrfGatewayCmd; +import org.apache.cloudstack.api.command.AssignNsxVrfGatewayCmd; +import org.apache.cloudstack.api.command.ListNsxVrfGatewaysCmd; import org.apache.cloudstack.api.response.NsxControllerResponse; +import org.apache.cloudstack.api.response.NsxVrfGatewayResponse; import java.util.List; @@ -32,4 +36,18 @@ public interface NsxProviderService extends PluggableService { List listNsxProviders(Long zoneId); boolean deleteNsxController(Long nsxControllerId); + + /** Registers a tier-0 gateway the operator has already staged in NSX. */ + NsxVrfGatewayResponse addNsxVrfGateway(AddNsxVrfGatewayCmd cmd); + + /** Claims a registered gateway for an account or a domain. */ + NsxVrfGatewayResponse assignNsxVrfGateway(AssignNsxVrfGatewayCmd cmd); + + /** Returns a gateway to the unassigned pool. */ + NsxVrfGatewayResponse releaseNsxVrfGateway(Long id); + + List listNsxVrfGateways(ListNsxVrfGatewaysCmd cmd); + + /** Removes CloudStack's registration; the gateway stays in NSX. */ + boolean deleteNsxVrfGateway(Long id); } diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxProviderServiceImpl.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxProviderServiceImpl.java index c59ebfd87551..43e8fb7ab6e3 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxProviderServiceImpl.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxProviderServiceImpl.java @@ -40,12 +40,27 @@ import org.apache.cloudstack.api.command.DeleteNsxControllerCmd; import org.apache.cloudstack.api.command.ListNsxControllersCmd; import org.apache.cloudstack.api.BaseResponse; +import com.cloud.domain.DomainVO; +import com.cloud.domain.dao.DomainDao; +import com.cloud.network.dao.NsxVrfGatewayDao; +import com.cloud.network.element.NsxVrfGatewayVO; +import com.cloud.user.AccountVO; +import com.cloud.user.dao.AccountDao; import org.apache.cloudstack.api.command.AddNsxControllerCmd; +import org.apache.cloudstack.api.command.AddNsxVrfGatewayCmd; +import org.apache.cloudstack.api.command.AssignNsxVrfGatewayCmd; +import org.apache.cloudstack.api.command.DeleteNsxVrfGatewayCmd; +import org.apache.cloudstack.api.command.ListNsxVrfGatewaysCmd; +import org.apache.cloudstack.api.command.ReleaseNsxVrfGatewayCmd; +import org.apache.cloudstack.api.response.NsxVrfGatewayResponse; import org.apache.cloudstack.api.response.NsxControllerResponse; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; import org.apache.cloudstack.resource.NsxResource; import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + import javax.inject.Inject; import javax.naming.ConfigurationException; import java.util.ArrayList; @@ -57,6 +72,8 @@ public class NsxProviderServiceImpl implements NsxProviderService { + protected Logger logger = LogManager.getLogger(getClass()); + @Inject NsxProviderDao nsxProviderDao; @Inject @@ -69,6 +86,12 @@ public class NsxProviderServiceImpl implements NsxProviderService { ResourceManager resourceManager; @Inject HostDetailsDao hostDetailsDao; + @Inject + NsxVrfGatewayDao nsxVrfGatewayDao; + @Inject + AccountDao accountDao; + @Inject + DomainDao domainDao; @Override public NsxProvider addProvider(AddNsxControllerCmd cmd) { @@ -197,10 +220,229 @@ public List> getCommands() { cmdList.add(AddNsxControllerCmd.class); cmdList.add(ListNsxControllersCmd.class); cmdList.add(DeleteNsxControllerCmd.class); + cmdList.add(AddNsxVrfGatewayCmd.class); + cmdList.add(AssignNsxVrfGatewayCmd.class); + cmdList.add(ReleaseNsxVrfGatewayCmd.class); + cmdList.add(ListNsxVrfGatewaysCmd.class); + cmdList.add(DeleteNsxVrfGatewayCmd.class); } return cmdList; } + @Override + public NsxVrfGatewayResponse addNsxVrfGateway(AddNsxVrfGatewayCmd cmd) { + DataCenterVO zone = dataCenterDao.findById(cmd.getZoneId()); + if (zone == null) { + throw new InvalidParameterValueException("Could not find zone with the given ID"); + } + if (nsxProviderDao.findByZoneId(zone.getId()) == null) { + throw new InvalidParameterValueException(String.format("Zone %s has no NSX controller registered", zone)); + } + if (nsxVrfGatewayDao.findByZoneAndTier0Name(zone.getId(), cmd.getTier0Gateway()) != null) { + throw new InvalidParameterValueException(String.format( + "Tier-0 gateway %s is already registered in zone %s", cmd.getTier0Gateway(), zone)); + } + + NsxVrfGatewayVO gateway = new NsxVrfGatewayVO(zone.getId(), cmd.getTier0Gateway(), + cmd.getEdgeCluster(), cmd.getParentTier0Gateway()); + gateway = nsxVrfGatewayDao.persist(gateway); + logger.debug("Registered NSX VRF gateway {} in zone {}", gateway, zone); + return createNsxVrfGatewayResponse(gateway); + } + + @Override + public NsxVrfGatewayResponse assignNsxVrfGateway(AssignNsxVrfGatewayCmd cmd) { + NsxVrfGatewayVO gateway = getVrfGatewayOrFail(cmd.getId()); + Long accountId = cmd.getAccountId(); + Long domainId = cmd.getDomainId(); + + if ((accountId == null) == (domainId == null)) { + throw new InvalidParameterValueException("Specify exactly one of accountid or domainid"); + } + if (!gateway.isUnclaimed()) { + throw new InvalidParameterValueException(String.format( + "NSX VRF gateway %s is already assigned; release it first", gateway.getNsxTier0Name())); + } + + if (accountId != null) { + AccountVO account = accountDao.findById(accountId); + if (account == null) { + throw new InvalidParameterValueException("Could not find account with the given ID"); + } + NsxVrfGatewayVO existing = nsxVrfGatewayDao.findByAccount(gateway.getZoneId(), accountId); + if (existing != null) { + throw new InvalidParameterValueException(String.format( + "Account %s already has NSX VRF gateway %s in this zone", account, existing.getNsxTier0Name())); + } + gateway.setScope(NsxVrfGatewayVO.Scope.ACCOUNT.name()); + gateway.setAccountId(accountId); + gateway.setDomainId(account.getDomainId()); + } else { + DomainVO domain = domainDao.findById(domainId); + if (domain == null) { + throw new InvalidParameterValueException("Could not find domain with the given ID"); + } + NsxVrfGatewayVO existing = nsxVrfGatewayDao.findByDomain(gateway.getZoneId(), domainId); + if (existing != null) { + throw new InvalidParameterValueException(String.format( + "Domain %s already has NSX VRF gateway %s in this zone", domain, existing.getNsxTier0Name())); + } + gateway.setScope(NsxVrfGatewayVO.Scope.DOMAIN.name()); + gateway.setDomainId(domainId); + } + + if (!nsxVrfGatewayDao.update(gateway.getId(), gateway)) { + throw new CloudRuntimeException(String.format( + "Failed to assign NSX VRF gateway %s", gateway.getNsxTier0Name())); + } + logger.debug("Assigned NSX VRF gateway {}", gateway); + return createNsxVrfGatewayResponse(gateway); + } + + @Override + public NsxVrfGatewayResponse releaseNsxVrfGateway(Long id) { + NsxVrfGatewayVO gateway = getVrfGatewayOrFail(id); + if (gateway.isUnclaimed()) { + throw new InvalidParameterValueException(String.format( + "NSX VRF gateway %s is not assigned to anyone", gateway.getNsxTier0Name())); + } + // Re-parenting a live tier-1 onto a different tier-0 is not something CloudStack + // can do without disrupting the tenant, so refuse while networks are still using it. + long networks = countNetworksUsingVrfGateway(gateway); + if (networks > 0) { + throw new InvalidParameterValueException(String.format( + "NSX VRF gateway %s still has %d network(s) attached; remove them before releasing it", + gateway.getNsxTier0Name(), networks)); + } + // Must go through the setters: GenericDaoBase builds its UPDATE from setter calls + // intercepted on the enhanced entity, so clearing the fields directly would write + // nothing while still reporting success. + gateway.setScope(null); + gateway.setAccountId(null); + gateway.setDomainId(null); + gateway.setPublicVlanDbId(null); + if (!nsxVrfGatewayDao.update(gateway.getId(), gateway)) { + throw new CloudRuntimeException(String.format( + "Failed to release NSX VRF gateway %s", gateway.getNsxTier0Name())); + } + logger.debug("Released NSX VRF gateway {}", gateway); + return createNsxVrfGatewayResponse(gateway); + } + + @Override + public List listNsxVrfGateways(ListNsxVrfGatewaysCmd cmd) { + List gateways; + if (cmd.getAccountId() != null) { + gateways = new ArrayList<>(); + for (DataCenterVO zone : listZonesToSearch(cmd.getZoneId())) { + NsxVrfGatewayVO gateway = nsxVrfGatewayDao.findByAccount(zone.getId(), cmd.getAccountId()); + if (gateway != null) { + gateways.add(gateway); + } + } + } else if (cmd.getDomainId() != null) { + gateways = new ArrayList<>(); + for (DataCenterVO zone : listZonesToSearch(cmd.getZoneId())) { + NsxVrfGatewayVO gateway = nsxVrfGatewayDao.findByDomain(zone.getId(), cmd.getDomainId()); + if (gateway != null) { + gateways.add(gateway); + } + } + } else if (cmd.getZoneId() != null) { + gateways = nsxVrfGatewayDao.listByZone(cmd.getZoneId()); + } else { + gateways = nsxVrfGatewayDao.listAll(); + } + + List responses = new ArrayList<>(); + for (NsxVrfGatewayVO gateway : gateways) { + if (cmd.getAllocatedOnly() != null && cmd.getAllocatedOnly() == gateway.isUnclaimed()) { + continue; + } + responses.add(createNsxVrfGatewayResponse(gateway)); + } + return responses; + } + + @Override + public boolean deleteNsxVrfGateway(Long id) { + NsxVrfGatewayVO gateway = getVrfGatewayOrFail(id); + if (!gateway.isUnclaimed()) { + throw new InvalidParameterValueException(String.format( + "NSX VRF gateway %s is still assigned; release it first", gateway.getNsxTier0Name())); + } + logger.debug("Deregistering NSX VRF gateway {}; it is left in place in NSX", gateway); + return nsxVrfGatewayDao.remove(id); + } + + private List listZonesToSearch(Long zoneId) { + if (zoneId == null) { + return dataCenterDao.listAll(); + } + DataCenterVO zone = dataCenterDao.findById(zoneId); + return zone == null ? new ArrayList<>() : List.of(zone); + } + + private NsxVrfGatewayVO getVrfGatewayOrFail(Long id) { + NsxVrfGatewayVO gateway = id == null ? null : nsxVrfGatewayDao.findById(id); + if (gateway == null) { + throw new InvalidParameterValueException("Could not find NSX VRF gateway with the given ID"); + } + return gateway; + } + + @VisibleForTesting + long countNetworksUsingVrfGateway(NsxVrfGatewayVO gateway) { + List networks = networkDao.listByZone(gateway.getZoneId()); + if (CollectionUtils.isNullOrEmpty(networks)) { + return 0; + } + return networks.stream() + .filter(network -> network.getBroadcastDomainType() == Networks.BroadcastDomainType.NSX) + .filter(network -> network.getRemoved() == null) + .filter(network -> matchesTenant(gateway, network)) + .count(); + } + + private boolean matchesTenant(NsxVrfGatewayVO gateway, NetworkVO network) { + if (gateway.getAccountId() != null) { + return gateway.getAccountId().equals(network.getAccountId()); + } + return gateway.getDomainId() != null && gateway.getDomainId().equals(network.getDomainId()); + } + + @VisibleForTesting + NsxVrfGatewayResponse createNsxVrfGatewayResponse(NsxVrfGatewayVO gateway) { + NsxVrfGatewayResponse response = new NsxVrfGatewayResponse(); + response.setId(gateway.getUuid()); + response.setTier0Gateway(gateway.getNsxTier0Name()); + response.setEdgeCluster(gateway.getEdgeCluster()); + response.setParentTier0Gateway(gateway.getParentTier0()); + response.setScope(gateway.getScope()); + response.setAllocated(!gateway.isUnclaimed()); + + DataCenterVO zone = dataCenterDao.findById(gateway.getZoneId()); + if (zone != null) { + response.setZoneId(zone.getUuid()); + response.setZoneName(zone.getName()); + } + if (gateway.getAccountId() != null) { + AccountVO account = accountDao.findById(gateway.getAccountId()); + if (account != null) { + response.setAccountId(account.getUuid()); + response.setAccountName(account.getAccountName()); + } + } + if (gateway.getDomainId() != null) { + DomainVO domain = domainDao.findById(gateway.getDomainId()); + if (domain != null) { + response.setDomainId(domain.getUuid()); + response.setDomainName(domain.getName()); + } + } + return response; + } + @VisibleForTesting void validateNetworkState(List networkList) { for (NetworkVO network : networkList) { 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..31aaceff4183 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,45 +35,228 @@ 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.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.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.vpc.Vpc; 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.domain.DomainVO; +import com.cloud.domain.dao.DomainDao; +import com.cloud.network.dao.NsxVrfGatewayDao; +import com.cloud.network.element.NsxVrfGatewayVO; import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.commons.lang3.StringUtils; + +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); + + public static final ConfigKey NSX_VRF_SCOPE = new ConfigKey<>(String.class, + "nsx.vrf.scope", "Advanced", "NONE", + "Routing domain granularity for NSX zones. NONE (default) attaches every tenant's Tier-1 " + + "gateways to the zone-wide Tier-0, which is the behaviour before per-tenant VRFs existed. " + + "ACCOUNT or DOMAIN instead attaches them to the VRF (or dedicated) Tier-0 registered for " + + "that tenant, giving each tenant its own routing table", + true, ConfigKey.Scope.Zone, null, null, null, null, null, + ConfigKey.Kind.Select, "NONE,ACCOUNT,DOMAIN"); + + public static final ConfigKey NSX_VRF_FALLBACK_TO_SHARED_TIER0 = new ConfigKey<>("Advanced", Boolean.class, + "nsx.vrf.fallback.to.shared.tier0", "false", + "Only consulted when nsx.vrf.scope is not NONE. When false, creating a network or VPC for a tenant " + + "with no NSX VRF gateway registered fails. When true, such tenants silently fall back to the " + + "zone-wide Tier-0 — convenient, but it puts them back in the shared routing table", + true, ConfigKey.Scope.Zone); + + protected static final String NSX_VRF_SCOPE_NONE = "NONE"; + + 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.Connecting, Site2SiteVpnConnection.State.Connected, + Site2SiteVpnConnection.State.Disconnected); -public class NsxServiceImpl implements NsxService, Configurable { @Inject NsxControllerUtils nsxControllerUtils; @Inject VpcDao vpcDao; + @Inject + VpcOfferingServiceMapDao vpcOfferingServiceMapDao; + @Inject + Site2SiteVpnConnectionDao site2SiteVpnConnectionDao; + @Inject + Site2SiteVpnGatewayDao site2SiteVpnGatewayDao; + @Inject + AlertManager alertManager; + @Inject + NsxVrfGatewayDao nsxVrfGatewayDao; + @Inject + DomainDao domainDao; 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 { + vpnStatusPollExecutor = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("Nsx-Vpn-Status-Poll")); + return true; + } + + @Override + public boolean start() { + 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 true; + } + 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); + applyVrfGateway(createNsxTier1GatewayCommand, zoneId, accountId, domainId); NsxAnswer result = nsxControllerUtils.sendNsxCommand(createNsxTier1GatewayCommand, zoneId); return result.getResult(); } + /** + * Points the new Tier-1 gateway at the tenant's own Tier-0 and edge cluster, when the + * zone is VRF-segregated and the tenant has one registered. Leaves the command + * untouched otherwise, so the resource falls back to the zone-wide values. + */ + protected void applyVrfGateway(CreateNsxTier1GatewayCommand cmd, Long zoneId, long accountId, long domainId) { + NsxVrfGatewayVO gateway = resolveVrfGateway(zoneId, accountId, domainId); + if (gateway == null) { + return; + } + cmd.setTier0Gateway(gateway.getNsxTier0Name()); + cmd.setEdgeCluster(gateway.getEdgeCluster()); + } + + /** + * Finds the NSX VRF gateway a tenant's Tier-1 gateways should attach to. + * + * Resolution order is exact account, then the tenant's domain, then each ancestor + * domain in turn — so a reseller can register one routing domain per customer rather + * than one per CloudStack account. + * + * @return the tenant's gateway, or null when the zone is not VRF-segregated or when + * no gateway is registered and falling back to the shared Tier-0 is allowed + * @throws CloudRuntimeException when the zone is VRF-segregated, the tenant has no + * gateway, and fallback is disabled — silently dropping a tenant into the + * shared routing table of a zone declared segregated is the worse failure + */ + protected NsxVrfGatewayVO resolveVrfGateway(Long zoneId, long accountId, long domainId) { + if (zoneId == null) { + return null; + } + String scope = getVrfScope(zoneId); + if (StringUtils.isBlank(scope) || NSX_VRF_SCOPE_NONE.equalsIgnoreCase(scope)) { + return null; + } + + NsxVrfGatewayVO gateway = null; + if (NsxVrfGatewayVO.Scope.ACCOUNT.name().equalsIgnoreCase(scope)) { + gateway = nsxVrfGatewayDao.findByAccount(zoneId, accountId); + } + if (gateway == null) { + gateway = findVrfGatewayForDomainChain(zoneId, domainId); + } + + if (gateway != null) { + logger.debug("Resolved NSX VRF gateway {} for account {} in zone {}", gateway, accountId, zoneId); + return gateway; + } + + if (isVrfFallbackToSharedTier0Allowed(zoneId)) { + logger.warn("Zone {} has {}={} but account {} has no NSX VRF gateway registered; falling back to the " + + "zone-wide tier 0 because {} is enabled. This tenant shares a routing table with the others", + zoneId, NSX_VRF_SCOPE.key(), scope, accountId, NSX_VRF_FALLBACK_TO_SHARED_TIER0.key()); + return null; + } + throw new CloudRuntimeException(String.format( + "Zone %s is segregated by %s=%s but account %s has no NSX VRF gateway registered. Register one with " + + "addNsxVrfGateway and assign it, or enable %s to allow the shared tier 0", + zoneId, NSX_VRF_SCOPE.key(), scope, accountId, NSX_VRF_FALLBACK_TO_SHARED_TIER0.key())); + } + + /** Seam over the zone-scoped setting, so the resolution logic is unit-testable. */ + protected String getVrfScope(long zoneId) { + return NSX_VRF_SCOPE.valueIn(zoneId); + } + + /** Seam over the zone-scoped setting, so the resolution logic is unit-testable. */ + protected boolean isVrfFallbackToSharedTier0Allowed(long zoneId) { + return Boolean.TRUE.equals(NSX_VRF_FALLBACK_TO_SHARED_TIER0.valueIn(zoneId)); + } + + private NsxVrfGatewayVO findVrfGatewayForDomainChain(long zoneId, long domainId) { + DomainVO domain = domainDao.findById(domainId); + while (domain != null) { + NsxVrfGatewayVO gateway = nsxVrfGatewayDao.findByDomain(zoneId, domain.getId()); + if (gateway != null) { + return gateway; + } + Long parentId = domain.getParent(); + domain = parentId == null ? null : domainDao.findById(parentId); + } + return null; + } + @Override public boolean updateVpcSourceNatIp(Vpc vpc, IpAddress address) { if (vpc == null || address == null) { @@ -91,6 +282,7 @@ public boolean updateVpcSourceNatIp(Vpc vpc, IpAddress address) { public boolean createNetwork(Long zoneId, long accountId, long domainId, Long networkId, String networkName) { CreateNsxTier1GatewayCommand createNsxTier1GatewayCommand = new CreateNsxTier1GatewayCommand(domainId, accountId, zoneId, networkId, networkName, false, false); + applyVrfGateway(createNsxTier1GatewayCommand, zoneId, accountId, domainId); NsxAnswer result = nsxControllerUtils.sendNsxCommand(createNsxTier1GatewayCommand, zoneId); return result.getResult(); } @@ -196,6 +388,151 @@ public boolean deleteFirewallRules(Network network, List netRule return result.getResult(); } + public boolean createVpnGateway(Vpc vpc, String localEndpointIp) { + CreateNsxVpnGatewayCommand createNsxVpnGatewayCommand = new CreateNsxVpnGatewayCommand(vpc.getDomainId(), + vpc.getAccountId(), vpc.getZoneId(), vpc.getId(), vpc.getName(), localEndpointIp); + NsxAnswer result = nsxControllerUtils.sendNsxCommand(createNsxVpnGatewayCommand, vpc.getZoneId()); + return result.getResult(); + } + + 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 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 || vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId( + Network.Service.Vpn.getName(), Network.Provider.Nsx.getName(), vpc.getVpcOfferingId()) == null) { + 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); + } + } + } + + 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.Connecting) { + // the async connection job may still be creating the session on NSX + 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 +541,8 @@ 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, + NSX_VRF_SCOPE, NSX_VRF_FALLBACK_TO_SHARED_TIER0 }; } 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..0ef3e2dd63ba 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 @@ -138,6 +138,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..79f1c0e018e4 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,17 +22,55 @@ 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.exception.CloudRuntimeException; +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; import java.util.List; +import java.util.Set; 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. + * Collisions between connections whose ids are congruent mod 4096 are resolved by + * {@link #findFreeVpnVtiAddressPair(String, Set)} against the tier-1's in-use VTI addresses. + */ + 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)); + } + + /** + * Returns the preferred VTI /30 when its local address is not in use on the tier-1 gateway, + * otherwise linear-probes the following slots (wrapping within 169.254.64.0/18) for the first + * free one; throws once all 4096 slots are taken. + */ + public static Pair findFreeVpnVtiAddressPair(String preferredVtiLocalIp, Set inUseVtiLocalIps) { + long startSlot = (NetUtils.ip2Long(preferredVtiLocalIp) - 1 - VPN_VTI_SUBNET_BASE) / 4; + for (long probe = 0; probe < VPN_VTI_SUBNET_SLOTS; probe++) { + long slotBase = VPN_VTI_SUBNET_BASE + ((startSlot + probe) % VPN_VTI_SUBNET_SLOTS) * 4; + String vtiLocalIp = NetUtils.long2Ip(slotBase + 1); + if (!inUseVtiLocalIps.contains(vtiLocalIp)) { + return new Pair<>(vtiLocalIp, NetUtils.long2Ip(slotBase + 2)); + } + } + throw new CloudRuntimeException("No free VTI slots are left in 169.254.64.0/18 for the Site-to-Site VPN connection"); + } + 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/service/NsxElementTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxElementTest.java index a807155a2dc1..7eb784e113f2 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,11 +23,17 @@ 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; @@ -36,6 +42,10 @@ 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.rules.FirewallRule; @@ -46,13 +56,17 @@ 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 +75,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 +97,14 @@ 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.verify; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -127,6 +148,18 @@ public class NsxElementTest { LoadBalancerVMMapDao lbVmMapDao; @Mock FirewallRuleDetailsDao firewallRuleDetailsDao; + @Mock + IpAddressManager ipAddressManager; + @Mock + VpcService vpcService; + @Mock + Site2SiteVpnGatewayDao vpnGatewayDao; + @Mock + Site2SiteCustomerGatewayDao customerGatewayDao; + @Mock + UserIpAddressDetailsDao userIpAddressDetailsDao; + @Mock + FirewallRulesDao firewallRulesDao; NsxElement nsxElement; ReservationContext reservationContext; @@ -152,6 +185,12 @@ public void setup() throws NoSuchFieldException, IllegalAccessException { nsxElement.vpcDao = vpcDao; nsxElement.lbVmMapDao = lbVmMapDao; nsxElement.firewallRuleDetailsDao = firewallRuleDetailsDao; + nsxElement.ipAddressManager = ipAddressManager; + nsxElement.vpcService = vpcService; + nsxElement.vpnGatewayDao = vpnGatewayDao; + nsxElement.customerGatewayDao = customerGatewayDao; + nsxElement.userIpAddressDetailsDao = userIpAddressDetailsDao; + nsxElement.firewallRulesDao = firewallRulesDao; Field field = ApiDBUtils.class.getDeclaredField("s_ipAddressDao"); field.setAccessible(true); @@ -470,4 +509,200 @@ 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)); + 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(firewallRulesDao.countRulesByIpId(20L)).thenReturn(0L); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.20")).thenReturn(true); + + IpAddress result = nsxElement.acquireVpnGatewayIp(vpcVO, requestedIp); + assertEquals(ipAddressVO, result); + } + + @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.isSourceNat()).thenReturn(true); + when(ipAddressVO.getAddress()).thenReturn(new Ip("10.1.13.20")); + + 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(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 + 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()); + } + + 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 + 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 + public void testStopSite2SiteVpn() 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.stopSite2SiteVpn(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/NsxProviderServiceImplTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxProviderServiceImplTest.java index cb6f6511d24d..824ea274da65 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxProviderServiceImplTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxProviderServiceImplTest.java @@ -33,7 +33,17 @@ import com.cloud.resource.ServerResource; import com.cloud.utils.exception.CloudRuntimeException; import org.apache.cloudstack.api.BaseResponse; +import com.cloud.domain.dao.DomainDao; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.network.dao.NsxVrfGatewayDao; +import com.cloud.network.element.NsxVrfGatewayVO; +import com.cloud.user.AccountVO; +import com.cloud.user.dao.AccountDao; import org.apache.cloudstack.api.command.AddNsxControllerCmd; +import org.apache.cloudstack.api.command.AddNsxVrfGatewayCmd; +import org.apache.cloudstack.api.command.AssignNsxVrfGatewayCmd; +import org.apache.cloudstack.api.command.ListNsxVrfGatewaysCmd; +import org.apache.cloudstack.api.response.NsxVrfGatewayResponse; import org.apache.cloudstack.api.response.NsxControllerResponse; import org.junit.Assert; import org.junit.Before; @@ -53,8 +63,10 @@ import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -71,6 +83,12 @@ public class NsxProviderServiceImplTest { ResourceManager resourceManager; @Mock HostDetailsDao hostDetailsDao; + @Mock + NsxVrfGatewayDao nsxVrfGatewayDao; + @Mock + AccountDao accountDao; + @Mock + DomainDao domainDao; NsxProviderServiceImpl nsxProviderService; @@ -83,6 +101,9 @@ public void setup() { nsxProviderService.dataCenterDao = dataCenterDao; nsxProviderService.networkDao = networkDao; nsxProviderService.physicalNetworkDao = physicalNetworkDao; + nsxProviderService.nsxVrfGatewayDao = nsxVrfGatewayDao; + nsxProviderService.accountDao = accountDao; + nsxProviderService.domainDao = domainDao; } @Test @@ -171,4 +192,189 @@ public void testNetworkStateValidation() throws NoSuchMethodException, Invocatio assertThrows(CloudRuntimeException.class, () -> nsxProviderService.validateNetworkState(networkVOList)); } + + private static final long VRF_ZONE_ID = 1L; + + private NsxVrfGatewayVO stagedGateway() { + return new NsxVrfGatewayVO(VRF_ZONE_ID, "CS-VRF-001", "v5-EdgeCluster-HOSTED", "v5-T0"); + } + + private AddNsxVrfGatewayCmd addCmd() { + AddNsxVrfGatewayCmd cmd = mock(AddNsxVrfGatewayCmd.class); + when(cmd.getZoneId()).thenReturn(VRF_ZONE_ID); + when(cmd.getTier0Gateway()).thenReturn("CS-VRF-001"); + return cmd; + } + + @Test + public void testAddNsxVrfGatewayPersistsTheRegistration() { + DataCenterVO zone = mock(DataCenterVO.class); + when(zone.getId()).thenReturn(VRF_ZONE_ID); + when(dataCenterDao.findById(VRF_ZONE_ID)).thenReturn(zone); + when(nsxProviderDao.findByZoneId(VRF_ZONE_ID)).thenReturn(mock(NsxProviderVO.class)); + when(nsxVrfGatewayDao.findByZoneAndTier0Name(VRF_ZONE_ID, "CS-VRF-001")).thenReturn(null); + when(nsxVrfGatewayDao.persist(any(NsxVrfGatewayVO.class))).thenAnswer(i -> i.getArgument(0)); + + AddNsxVrfGatewayCmd cmd = addCmd(); + when(cmd.getEdgeCluster()).thenReturn("v5-EdgeCluster-HOSTED"); + when(cmd.getParentTier0Gateway()).thenReturn("v5-T0"); + + NsxVrfGatewayResponse response = nsxProviderService.addNsxVrfGateway(cmd); + + assertEquals("CS-VRF-001", response.getTier0Gateway()); + assertEquals("v5-EdgeCluster-HOSTED", response.getEdgeCluster()); + Assert.assertFalse(response.isAllocated()); + } + + @Test + public void testAddNsxVrfGatewayRejectsDuplicateTier0InTheSameZone() { + DataCenterVO zone = mock(DataCenterVO.class); + when(zone.getId()).thenReturn(VRF_ZONE_ID); + when(dataCenterDao.findById(VRF_ZONE_ID)).thenReturn(zone); + when(nsxProviderDao.findByZoneId(VRF_ZONE_ID)).thenReturn(mock(NsxProviderVO.class)); + when(nsxVrfGatewayDao.findByZoneAndTier0Name(VRF_ZONE_ID, "CS-VRF-001")).thenReturn(stagedGateway()); + + assertThrows(InvalidParameterValueException.class, () -> nsxProviderService.addNsxVrfGateway(addCmd())); + } + + @Test + public void testAddNsxVrfGatewayRejectsZoneWithoutNsxController() { + DataCenterVO zone = mock(DataCenterVO.class); + when(zone.getId()).thenReturn(VRF_ZONE_ID); + when(dataCenterDao.findById(VRF_ZONE_ID)).thenReturn(zone); + when(nsxProviderDao.findByZoneId(VRF_ZONE_ID)).thenReturn(null); + + assertThrows(InvalidParameterValueException.class, () -> nsxProviderService.addNsxVrfGateway(addCmd())); + } + + @Test + public void testAssignNsxVrfGatewayToAccount() { + NsxVrfGatewayVO gateway = stagedGateway(); + when(nsxVrfGatewayDao.findById(5L)).thenReturn(gateway); + AccountVO account = mock(AccountVO.class); + when(account.getDomainId()).thenReturn(2L); + when(accountDao.findById(9L)).thenReturn(account); + when(nsxVrfGatewayDao.findByAccount(VRF_ZONE_ID, 9L)).thenReturn(null); + when(nsxVrfGatewayDao.update(anyLong(), any(NsxVrfGatewayVO.class))).thenReturn(true); + + AssignNsxVrfGatewayCmd cmd = mock(AssignNsxVrfGatewayCmd.class); + when(cmd.getId()).thenReturn(5L); + when(cmd.getAccountId()).thenReturn(9L); + when(cmd.getDomainId()).thenReturn(null); + + NsxVrfGatewayResponse response = nsxProviderService.assignNsxVrfGateway(cmd); + + assertEquals(NsxVrfGatewayVO.Scope.ACCOUNT.name(), gateway.getScope()); + assertEquals(Long.valueOf(9L), gateway.getAccountId()); + assertTrue(response.isAllocated()); + } + + @Test + public void testAssignNsxVrfGatewayRequiresExactlyOneOfAccountOrDomain() { + when(nsxVrfGatewayDao.findById(5L)).thenReturn(stagedGateway()); + AssignNsxVrfGatewayCmd cmd = mock(AssignNsxVrfGatewayCmd.class); + when(cmd.getId()).thenReturn(5L); + when(cmd.getAccountId()).thenReturn(9L); + when(cmd.getDomainId()).thenReturn(2L); + + assertThrows(InvalidParameterValueException.class, () -> nsxProviderService.assignNsxVrfGateway(cmd)); + } + + @Test + public void testAssignNsxVrfGatewayRejectsAlreadyAssignedGateway() { + NsxVrfGatewayVO gateway = stagedGateway(); + gateway.setScope(NsxVrfGatewayVO.Scope.ACCOUNT.name()); + gateway.setAccountId(3L); + when(nsxVrfGatewayDao.findById(5L)).thenReturn(gateway); + + AssignNsxVrfGatewayCmd cmd = mock(AssignNsxVrfGatewayCmd.class); + when(cmd.getId()).thenReturn(5L); + when(cmd.getAccountId()).thenReturn(9L); + when(cmd.getDomainId()).thenReturn(null); + + assertThrows(InvalidParameterValueException.class, () -> nsxProviderService.assignNsxVrfGateway(cmd)); + } + + @Test + public void testReleaseNsxVrfGatewayRefusesWhileNetworksAreAttached() { + NsxVrfGatewayVO gateway = stagedGateway(); + gateway.setScope(NsxVrfGatewayVO.Scope.ACCOUNT.name()); + gateway.setAccountId(9L); + when(nsxVrfGatewayDao.findById(5L)).thenReturn(gateway); + + NetworkVO network = mock(NetworkVO.class); + when(network.getBroadcastDomainType()).thenReturn(Networks.BroadcastDomainType.NSX); + when(network.getRemoved()).thenReturn(null); + when(network.getAccountId()).thenReturn(9L); + when(networkDao.listByZone(VRF_ZONE_ID)).thenReturn(List.of(network)); + + assertThrows(InvalidParameterValueException.class, () -> nsxProviderService.releaseNsxVrfGateway(5L)); + } + + @Test + public void testReleaseNsxVrfGatewayReturnsItToThePool() { + NsxVrfGatewayVO gateway = stagedGateway(); + gateway.setScope(NsxVrfGatewayVO.Scope.ACCOUNT.name()); + gateway.setAccountId(9L); + when(nsxVrfGatewayDao.findById(5L)).thenReturn(gateway); + when(networkDao.listByZone(VRF_ZONE_ID)).thenReturn(List.of()); + when(nsxVrfGatewayDao.update(anyLong(), any(NsxVrfGatewayVO.class))).thenReturn(true); + + NsxVrfGatewayResponse response = nsxProviderService.releaseNsxVrfGateway(5L); + + assertTrue(gateway.isUnclaimed()); + Assert.assertFalse(response.isAllocated()); + verify(nsxVrfGatewayDao).update(eq(gateway.getId()), eq(gateway)); + } + + /** + * Regression test for a release that reported success while persisting nothing. + * + * Entities are CGLIB-enhanced and GenericDaoBase builds its UPDATE from setter calls + * intercepted by UpdateBuilder. Clearing the fields directly on the VO left the row + * untouched in the database, so the gateway stayed assigned and could never be + * deregistered. Asserting on the returned object alone did not catch it, because that + * object is the in-memory VO; the DAO contract is what matters. + */ + @Test + public void testReleaseNsxVrfGatewayFailsLoudlyWhenTheUpdateDoesNotPersist() { + NsxVrfGatewayVO gateway = stagedGateway(); + gateway.setScope(NsxVrfGatewayVO.Scope.ACCOUNT.name()); + gateway.setAccountId(9L); + when(nsxVrfGatewayDao.findById(5L)).thenReturn(gateway); + when(networkDao.listByZone(VRF_ZONE_ID)).thenReturn(List.of()); + when(nsxVrfGatewayDao.update(anyLong(), any(NsxVrfGatewayVO.class))).thenReturn(false); + + assertThrows(CloudRuntimeException.class, () -> nsxProviderService.releaseNsxVrfGateway(5L)); + } + + @Test + public void testDeleteNsxVrfGatewayRefusesWhileAssigned() { + NsxVrfGatewayVO gateway = stagedGateway(); + gateway.setScope(NsxVrfGatewayVO.Scope.ACCOUNT.name()); + gateway.setAccountId(9L); + when(nsxVrfGatewayDao.findById(5L)).thenReturn(gateway); + + assertThrows(InvalidParameterValueException.class, () -> nsxProviderService.deleteNsxVrfGateway(5L)); + } + + @Test + public void testListNsxVrfGatewaysFiltersByAllocation() { + NsxVrfGatewayVO unclaimed = stagedGateway(); + NsxVrfGatewayVO claimed = new NsxVrfGatewayVO(VRF_ZONE_ID, "CS-VRF-002", "v5-EdgeCluster-HOSTED", "v5-T0"); + claimed.setScope(NsxVrfGatewayVO.Scope.ACCOUNT.name()); + claimed.setAccountId(9L); + when(nsxVrfGatewayDao.listByZone(VRF_ZONE_ID)).thenReturn(List.of(unclaimed, claimed)); + + ListNsxVrfGatewaysCmd cmd = mock(ListNsxVrfGatewaysCmd.class); + when(cmd.getZoneId()).thenReturn(VRF_ZONE_ID); + when(cmd.getAccountId()).thenReturn(null); + when(cmd.getDomainId()).thenReturn(null); + when(cmd.getAllocatedOnly()).thenReturn(Boolean.TRUE); + + List responses = nsxProviderService.listNsxVrfGateways(cmd); + + assertEquals(1, responses.size()); + assertEquals("CS-VRF-002", responses.get(0).getTier0Gateway()); + } } 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..a59ef8bdfd59 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java @@ -16,33 +16,59 @@ // under the License. package org.apache.cloudstack.service; +import java.util.List; + +import com.cloud.alert.AlertManager; +import com.cloud.domain.DomainVO; +import com.cloud.domain.dao.DomainDao; +import com.cloud.network.dao.NsxVrfGatewayDao; +import com.cloud.network.element.NsxVrfGatewayVO; import com.cloud.network.IpAddress; +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.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; +import com.cloud.network.vpc.dao.VpcOfferingServiceMapDao; +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.CreateNsxVpnConnectionCommand; +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; 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.utils.NsxControllerUtils; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.junit.MockitoJUnitRunner; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; 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; @RunWith(MockitoJUnitRunner.class) @@ -51,6 +77,18 @@ public class NsxServiceImplTest { private NsxControllerUtils nsxControllerUtils; @Mock private VpcDao vpcDao; + @Mock + private VpcOfferingServiceMapDao vpcOfferingServiceMapDao; + @Mock + private Site2SiteVpnConnectionDao site2SiteVpnConnectionDao; + @Mock + private Site2SiteVpnGatewayDao site2SiteVpnGatewayDao; + @Mock + private AlertManager alertManager; + @Mock + private NsxVrfGatewayDao nsxVrfGatewayDao; + @Mock + private DomainDao domainDao; NsxServiceImpl nsxService; AutoCloseable closeable; @@ -62,9 +100,15 @@ public class NsxServiceImplTest { @Before public void setup() { closeable = MockitoAnnotations.openMocks(this); - nsxService = new NsxServiceImpl(); + nsxService = Mockito.spy(new NsxServiceImpl()); nsxService.nsxControllerUtils = nsxControllerUtils; nsxService.vpcDao = vpcDao; + nsxService.vpcOfferingServiceMapDao = vpcOfferingServiceMapDao; + nsxService.site2SiteVpnConnectionDao = site2SiteVpnConnectionDao; + nsxService.site2SiteVpnGatewayDao = site2SiteVpnGatewayDao; + nsxService.alertManager = alertManager; + nsxService.nsxVrfGatewayDao = nsxVrfGatewayDao; + nsxService.domainDao = domainDao; } @After @@ -72,6 +116,113 @@ public void teardown() throws Exception { closeable.close(); } + private NsxVrfGatewayVO vrfGateway(String tier0, String edgeCluster) { + return new NsxVrfGatewayVO(zoneId, tier0, edgeCluster, "v5-T0"); + } + + @Test + public void testResolveVrfGatewayReturnsNullWhenScopeIsNone() { + Mockito.doReturn("NONE").when(nsxService).getVrfScope(zoneId); + assertNull(nsxService.resolveVrfGateway(zoneId, accountId, domainId)); + verify(nsxVrfGatewayDao, never()).findByAccount(anyLong(), anyLong()); + } + + @Test + public void testResolveVrfGatewayReturnsNullWhenZoneIsNull() { + assertNull(nsxService.resolveVrfGateway(null, accountId, domainId)); + } + + @Test + public void testResolveVrfGatewayPrefersTheAccountAssignment() { + Mockito.doReturn("ACCOUNT").when(nsxService).getVrfScope(zoneId); + NsxVrfGatewayVO expected = vrfGateway("CS-VRF-001", "v5-EdgeCluster-HOSTED"); + when(nsxVrfGatewayDao.findByAccount(zoneId, accountId)).thenReturn(expected); + + assertEquals(expected, nsxService.resolveVrfGateway(zoneId, accountId, domainId)); + verify(domainDao, never()).findById(anyLong()); + } + + @Test + public void testResolveVrfGatewayFallsBackToTheDomainWhenTheAccountHasNone() { + Mockito.doReturn("ACCOUNT").when(nsxService).getVrfScope(zoneId); + NsxVrfGatewayVO expected = vrfGateway("CS-VRF-002", "v5-EdgeCluster-HOSTED"); + when(nsxVrfGatewayDao.findByAccount(zoneId, accountId)).thenReturn(null); + DomainVO domain = mock(DomainVO.class); + when(domain.getId()).thenReturn(domainId); + when(domainDao.findById(domainId)).thenReturn(domain); + when(nsxVrfGatewayDao.findByDomain(zoneId, domainId)).thenReturn(expected); + + assertEquals(expected, nsxService.resolveVrfGateway(zoneId, accountId, domainId)); + } + + @Test + public void testResolveVrfGatewayWalksUpTheDomainChain() { + Mockito.doReturn("DOMAIN").when(nsxService).getVrfScope(zoneId); + long parentDomainId = 7L; + NsxVrfGatewayVO expected = vrfGateway("CS-VRF-003", "v5-EdgeCluster-HOSTED"); + + DomainVO child = mock(DomainVO.class); + when(child.getId()).thenReturn(domainId); + when(child.getParent()).thenReturn(parentDomainId); + DomainVO parent = mock(DomainVO.class); + when(parent.getId()).thenReturn(parentDomainId); + + when(domainDao.findById(domainId)).thenReturn(child); + when(domainDao.findById(parentDomainId)).thenReturn(parent); + when(nsxVrfGatewayDao.findByDomain(zoneId, domainId)).thenReturn(null); + when(nsxVrfGatewayDao.findByDomain(zoneId, parentDomainId)).thenReturn(expected); + + assertEquals(expected, nsxService.resolveVrfGateway(zoneId, accountId, domainId)); + } + + @Test + public void testResolveVrfGatewayFallsBackToSharedTier0WhenAllowed() { + Mockito.doReturn("ACCOUNT").when(nsxService).getVrfScope(zoneId); + Mockito.doReturn(true).when(nsxService).isVrfFallbackToSharedTier0Allowed(zoneId); + when(nsxVrfGatewayDao.findByAccount(zoneId, accountId)).thenReturn(null); + when(domainDao.findById(domainId)).thenReturn(null); + + assertNull(nsxService.resolveVrfGateway(zoneId, accountId, domainId)); + } + + @Test(expected = CloudRuntimeException.class) + public void testResolveVrfGatewayFailsWhenSegregatedAndUnassigned() { + Mockito.doReturn("ACCOUNT").when(nsxService).getVrfScope(zoneId); + Mockito.doReturn(false).when(nsxService).isVrfFallbackToSharedTier0Allowed(zoneId); + when(nsxVrfGatewayDao.findByAccount(zoneId, accountId)).thenReturn(null); + when(domainDao.findById(domainId)).thenReturn(null); + + nsxService.resolveVrfGateway(zoneId, accountId, domainId); + } + + @Test + public void testApplyVrfGatewaySetsTier0AndEdgeClusterOnTheCommand() { + Mockito.doReturn("ACCOUNT").when(nsxService).getVrfScope(zoneId); + when(nsxVrfGatewayDao.findByAccount(zoneId, accountId)) + .thenReturn(vrfGateway("CS-VRF-001", "v5-EdgeCluster-HOSTED")); + CreateNsxTier1GatewayCommand cmd = + new CreateNsxTier1GatewayCommand(domainId, accountId, zoneId, 1L, "VPC01", true, true); + + nsxService.applyVrfGateway(cmd, zoneId, accountId, domainId); + + assertEquals("CS-VRF-001", cmd.getTier0Gateway()); + assertEquals("v5-EdgeCluster-HOSTED", cmd.getEdgeCluster()); + } + + @Test + public void testApplyVrfGatewayLeavesCommandUntouchedWhenScopeIsNone() { + Mockito.doReturn("NONE").when(nsxService).getVrfScope(zoneId); + CreateNsxTier1GatewayCommand cmd = + new CreateNsxTier1GatewayCommand(domainId, accountId, zoneId, 1L, "VPC01", true, true); + + nsxService.applyVrfGateway(cmd, zoneId, accountId, domainId); + + // Null on both means the resource keeps using the zone-wide values, which is what + // makes this change a no-op for every deployment that does not opt in. + assertNull(cmd.getTier0Gateway()); + assertNull(cmd.getEdgeCluster()); + } + @Test public void testCreateVpcNetwork() { NsxAnswer createNsxTier1GatewayAnswer = mock(NsxAnswer.class); @@ -159,4 +310,233 @@ public void testDeleteStaticNatRule() { nsxService.deleteStaticNatRule(zoneId, domainId, accountId, networkId, networkName, true); Mockito.verify(nsxControllerUtils).sendNsxCommand(any(DeleteNsxNatRuleCommand.class), eq(zoneId)); } + + private VpcVO mockVpc() { + VpcVO vpc = mock(VpcVO.class); + when(vpc.getDomainId()).thenReturn(domainId); + when(vpc.getAccountId()).thenReturn(accountId); + when(vpc.getZoneId()).thenReturn(zoneId); + when(vpc.getId()).thenReturn(9L); + when(vpc.getName()).thenReturn("VPC01"); + return vpc; + } + + @Test + public void testCreateVpnGateway() { + VpcVO vpc = mockVpc(); + NsxAnswer answer = mock(NsxAnswer.class); + when(answer.getResult()).thenReturn(true); + when(nsxControllerUtils.sendNsxCommand(any(CreateNsxVpnGatewayCommand.class), eq(zoneId))).thenReturn(answer); + + assertTrue(nsxService.createVpnGateway(vpc, "10.1.13.30")); + Mockito.verify(nsxControllerUtils).sendNsxCommand(any(CreateNsxVpnGatewayCommand.class), eq(zoneId)); + } + + @Test + public void testDeleteVpnGateway() { + VpcVO vpc = mockVpc(); + NsxAnswer answer = mock(NsxAnswer.class); + when(answer.getResult()).thenReturn(true); + when(nsxControllerUtils.sendNsxCommand(any(DeleteNsxVpnGatewayCommand.class), eq(zoneId))).thenReturn(answer); + + assertTrue(nsxService.deleteVpnGateway(vpc)); + Mockito.verify(nsxControllerUtils).sendNsxCommand(any(DeleteNsxVpnGatewayCommand.class), eq(zoneId)); + } + + @Test + public void testCreateVpnConnection() { + VpcVO vpc = mockVpc(); + when(vpc.getCidr()).thenReturn("10.10.0.0/16"); + NsxAnswer answer = mock(NsxAnswer.class); + when(answer.getResult()).thenReturn(true); + when(nsxControllerUtils.sendNsxCommand(any(CreateNsxVpnConnectionCommand.class), eq(zoneId))).thenReturn(answer); + + assertTrue(nsxService.createVpnConnection(vpc, "conn-uuid", "203.0.113.10", "presharedkey", + "aes256-sha256;modp2048", "aes128-sha1", 86400L, 3600L, true, "ikev2", false, + List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, "10.1.13.30")); + Mockito.verify(nsxControllerUtils).sendNsxCommand(any(CreateNsxVpnConnectionCommand.class), eq(zoneId)); + } + + @Test + public void testDeleteVpnConnection() { + VpcVO vpc = mockVpc(); + NsxAnswer answer = mock(NsxAnswer.class); + when(answer.getResult()).thenReturn(true); + when(nsxControllerUtils.sendNsxCommand(any(DeleteNsxVpnConnectionCommand.class), eq(zoneId))).thenReturn(answer); + + assertTrue(nsxService.deleteVpnConnection(vpc, "conn-uuid")); + Mockito.verify(nsxControllerUtils).sendNsxCommand(any(DeleteNsxVpnConnectionCommand.class), eq(zoneId)); + } + + @Test + public void testGetVpnConnectionStatus() { + VpcVO vpc = mockVpc(); + NsxAnswer answer = mock(NsxAnswer.class); + when(answer.getDetails()).thenReturn("UP"); + when(nsxControllerUtils.sendNsxCommand(any(GetNsxVpnSessionStatusCommand.class), eq(zoneId))).thenReturn(answer); + + assertEquals("UP", nsxService.getVpnConnectionStatus(vpc, "conn-uuid")); + } + + @Test + public void testCreateVpnConnectionCommandPayload() { + VpcVO vpc = mockVpc(); + when(vpc.getCidr()).thenReturn("10.10.0.0/16"); + NsxAnswer answer = mock(NsxAnswer.class); + when(answer.getResult()).thenReturn(true); + when(nsxControllerUtils.sendNsxCommand(any(CreateNsxVpnConnectionCommand.class), eq(zoneId))).thenReturn(answer); + + assertTrue(nsxService.createVpnConnection(vpc, "conn-uuid", "203.0.113.10", "presharedkey", + "aes256-sha256;modp2048", "aes128-sha1", 86400L, 3600L, true, "ikev2", true, + List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, "10.1.13.30")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CreateNsxVpnConnectionCommand.class); + verify(nsxControllerUtils).sendNsxCommand(captor.capture(), eq(zoneId)); + CreateNsxVpnConnectionCommand command = captor.getValue(); + assertEquals("conn-uuid", command.getConnectionUuid()); + assertEquals("203.0.113.10", command.getPeerAddress()); + assertEquals("presharedkey", command.getPsk()); + assertEquals("169.254.64.21", command.getVtiLocalIp()); + assertEquals("169.254.64.22", command.getVtiPeerIp()); + assertEquals(30, command.getVtiPrefixLength()); + assertEquals("10.10.0.0/16", command.getVpcCidr()); + assertEquals(List.of("192.168.100.0/24"), command.getPeerCidrs()); + assertEquals("10.1.13.30", command.getLocalEndpointIp()); + assertTrue(command.isPassive()); + } + + private Site2SiteVpnConnectionVO vpnConnection(Site2SiteVpnConnection.State state) { + Site2SiteVpnConnectionVO connection = new Site2SiteVpnConnectionVO(accountId, domainId, 4L, 5L, false); + connection.setState(state); + return connection; + } + + private NsxAnswer mockVpnStatusAnswer(String status) { + NsxAnswer answer = mock(NsxAnswer.class); + when(answer.getDetails()).thenReturn(status); + when(nsxControllerUtils.sendNsxCommand(any(GetNsxVpnSessionStatusCommand.class), eq(zoneId))).thenReturn(answer); + return answer; + } + + @Test + public void testPollVpnConnectionStatusTransitionsToConnectedOnUp() { + VpcVO vpc = mockVpc(); + Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Disconnected); + mockVpnStatusAnswer("UP"); + when(site2SiteVpnConnectionDao.acquireInLockTable(connection.getId())).thenReturn(connection); + when(site2SiteVpnConnectionDao.findById(connection.getId())).thenReturn(connection); + + nsxService.pollVpnConnectionStatus(connection, vpc); + + assertEquals(Site2SiteVpnConnection.State.Connected, connection.getState()); + verify(site2SiteVpnConnectionDao).persist(connection); + verify(alertManager).sendAlert(any(), eq(zoneId), isNull(), anyString(), anyString()); + } + + @Test + public void testPollVpnConnectionStatusTransitionsToDisconnectedOnDown() { + VpcVO vpc = mockVpc(); + Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Connected); + mockVpnStatusAnswer("DOWN"); + when(site2SiteVpnConnectionDao.acquireInLockTable(connection.getId())).thenReturn(connection); + when(site2SiteVpnConnectionDao.findById(connection.getId())).thenReturn(connection); + + nsxService.pollVpnConnectionStatus(connection, vpc); + + assertEquals(Site2SiteVpnConnection.State.Disconnected, connection.getState()); + verify(site2SiteVpnConnectionDao).persist(connection); + } + + @Test + public void testPollVpnConnectionStatusFailuresNeverTransitionState() { + VpcVO vpc = mockVpc(); + Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Connected); + when(nsxControllerUtils.sendNsxCommand(any(GetNsxVpnSessionStatusCommand.class), eq(zoneId))) + .thenThrow(new CloudRuntimeException("NSX unreachable")); + + for (int i = 0; i < NsxServiceImpl.VPN_STATUS_POLL_FAILURE_THRESHOLD; i++) { + nsxService.pollVpnConnectionStatus(connection, vpc); + } + + assertEquals(Site2SiteVpnConnection.State.Connected, connection.getState()); + verify(site2SiteVpnConnectionDao, never()).persist(any()); + verify(alertManager, times(1)).sendAlert(any(), eq(zoneId), isNull(), anyString(), anyString()); + + // the failure counter was reset at the threshold: a single further failure does not alert again + nsxService.pollVpnConnectionStatus(connection, vpc); + verify(alertManager, times(1)).sendAlert(any(), eq(zoneId), isNull(), anyString(), anyString()); + } + + @Test + public void testPollVpnConnectionStatusFailureCounterResetsOnSuccess() { + VpcVO vpc = mockVpc(); + Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Connected); + NsxAnswer answer = mock(NsxAnswer.class); + when(answer.getDetails()).thenReturn("UP"); + when(nsxControllerUtils.sendNsxCommand(any(GetNsxVpnSessionStatusCommand.class), eq(zoneId))) + .thenThrow(new CloudRuntimeException("NSX unreachable")) + .thenThrow(new CloudRuntimeException("NSX unreachable")) + .thenReturn(answer) + .thenThrow(new CloudRuntimeException("NSX unreachable")) + .thenThrow(new CloudRuntimeException("NSX unreachable")); + + for (int i = 0; i < 5; i++) { + nsxService.pollVpnConnectionStatus(connection, vpc); + } + + // never three consecutive failures, so no alert and no transition + verify(alertManager, never()).sendAlert(any(), anyLong(), isNull(), anyString(), anyString()); + assertEquals(Site2SiteVpnConnection.State.Connected, connection.getState()); + } + + @Test + public void testPollVpnConnectionStatusTransitionsToErrorWhenSessionVanished() { + VpcVO vpc = mockVpc(); + Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Connected); + mockVpnStatusAnswer("NOT_FOUND"); + when(site2SiteVpnConnectionDao.acquireInLockTable(connection.getId())).thenReturn(connection); + when(site2SiteVpnConnectionDao.findById(connection.getId())).thenReturn(connection); + + nsxService.pollVpnConnectionStatus(connection, vpc); + + assertEquals(Site2SiteVpnConnection.State.Error, connection.getState()); + verify(site2SiteVpnConnectionDao).persist(connection); + } + + @Test + public void testPollVpnConnectionStatusKeepsConnectingStateWhenSessionNotFound() { + VpcVO vpc = mockVpc(); + Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Connecting); + mockVpnStatusAnswer("NOT_FOUND"); + + nsxService.pollVpnConnectionStatus(connection, vpc); + + assertEquals(Site2SiteVpnConnection.State.Connecting, connection.getState()); + verify(site2SiteVpnConnectionDao, never()).persist(any()); + } + + @Test + public void testTransitionVpnConnectionStateSkipsWhenLockCannotBeAcquired() { + VpcVO vpc = mock(VpcVO.class); + Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Disconnected); + when(site2SiteVpnConnectionDao.acquireInLockTable(connection.getId())).thenReturn(null); + + nsxService.transitionVpnConnectionState(connection, vpc, Site2SiteVpnConnection.State.Connected); + + verify(site2SiteVpnConnectionDao, never()).persist(any()); + } + + @Test + public void testTransitionVpnConnectionStateRechecksStateUnderLock() { + VpcVO vpc = mock(VpcVO.class); + Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Disconnected); + Site2SiteVpnConnectionVO lockedConnection = vpnConnection(Site2SiteVpnConnection.State.Pending); + when(site2SiteVpnConnectionDao.acquireInLockTable(connection.getId())).thenReturn(connection); + when(site2SiteVpnConnectionDao.findById(connection.getId())).thenReturn(lockedConnection); + + nsxService.transitionVpnConnectionState(connection, vpc, Site2SiteVpnConnection.State.Connected); + + verify(site2SiteVpnConnectionDao, never()).persist(any()); + verify(alertManager, never()).sendAlert(any(), anyLong(), isNull(), anyString(), anyString()); + } } 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..10ba2f5f8dfc --- /dev/null +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/utils/NsxHelperTest.java @@ -0,0 +1,88 @@ +// 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.HashSet; +import java.util.Set; + +import org.junit.Test; + +import com.cloud.utils.Pair; +import com.cloud.utils.exception.CloudRuntimeException; + +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)); + } + + @Test + public void testFindFreeVpnVtiAddressPairReturnsPreferredSlotWhenFree() { + Pair vtiAddresses = NsxHelper.findFreeVpnVtiAddressPair("169.254.64.21", Set.of()); + assertEquals("169.254.64.21", vtiAddresses.first()); + assertEquals("169.254.64.22", vtiAddresses.second()); + } + + @Test + public void testFindFreeVpnVtiAddressPairProbesPastInUseSlots() { + Pair vtiAddresses = NsxHelper.findFreeVpnVtiAddressPair("169.254.64.21", + Set.of("169.254.64.21", "169.254.64.25")); + assertEquals("169.254.64.29", vtiAddresses.first()); + assertEquals("169.254.64.30", vtiAddresses.second()); + } + + @Test + public void testFindFreeVpnVtiAddressPairWrapsAroundTheSubnet() { + Pair vtiAddresses = NsxHelper.findFreeVpnVtiAddressPair("169.254.127.253", + Set.of("169.254.127.253")); + assertEquals("169.254.64.1", vtiAddresses.first()); + assertEquals("169.254.64.2", vtiAddresses.second()); + } + + @Test(expected = CloudRuntimeException.class) + public void testFindFreeVpnVtiAddressPairThrowsWhenAllSlotsAreInUse() { + Set inUseVtiLocalIps = new HashSet<>(); + for (long slot = 0; slot < 4096; slot++) { + inUseVtiLocalIps.add(NsxHelper.getVpnVtiAddressPair(slot).first()); + } + NsxHelper.findFreeVpnVtiAddressPair("169.254.64.1", inUseVtiLocalIps); + } +} 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/NetworkMigrationManagerImpl.java b/server/src/main/java/com/cloud/network/NetworkMigrationManagerImpl.java index a09867b8ffc3..6e2193f2c23e 100644 --- a/server/src/main/java/com/cloud/network/NetworkMigrationManagerImpl.java +++ b/server/src/main/java/com/cloud/network/NetworkMigrationManagerImpl.java @@ -232,6 +232,7 @@ public class NetworkMigrationManagerImpl implements NetworkMigrationManager { copiedNetwork.setDisplayNetwork(false); copiedNetwork.setBroadcastUri(network.getBroadcastUri()); copiedNetwork.setState(network.getState()); + copiedNetwork.setNetworkACLId(network.getNetworkACLId()); _networksDao.update(networkCopyId, copiedNetwork); copyNetworkDetails(originalNetworkId, networkCopyId); diff --git a/server/src/main/java/com/cloud/network/router/VpcVirtualNetworkApplianceManagerImpl.java b/server/src/main/java/com/cloud/network/router/VpcVirtualNetworkApplianceManagerImpl.java index 49c2243d1ecf..a9575380df32 100644 --- a/server/src/main/java/com/cloud/network/router/VpcVirtualNetworkApplianceManagerImpl.java +++ b/server/src/main/java/com/cloud/network/router/VpcVirtualNetworkApplianceManagerImpl.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; import javax.inject.Inject; import javax.naming.ConfigurationException; @@ -370,7 +371,9 @@ private void appendSourceNatIpToBootArgs(final VirtualMachineProfile profile) { final StringBuilder buf = profile.getBootArgsBuilder(); final DomainRouterVO router = _routerDao.findById(profile.getVirtualMachine().getId()); if (router != null && router.getVpcId() != null) { - List vpcIps = _ipAddressDao.listByAssociatedVpc(router.getVpcId(), true); + List vpcIps = _ipAddressDao.listByAssociatedVpc(router.getVpcId(), true).stream() + .filter(ip -> !ip.isForSystemVms()) + .collect(Collectors.toList()); if (CollectionUtils.isNotEmpty(vpcIps)) { buf.append(String.format(" source_nat_ip=%s", vpcIps.get(0).getAddress().toString())); logger.debug("The final Boot Args for " + profile + ": " + buf); diff --git a/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java b/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java index bcf2c6176efe..e62a5c0f9260 100644 --- a/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java @@ -36,6 +36,7 @@ import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import com.cloud.event.UsageEventUtils; import javax.annotation.PostConstruct; @@ -223,6 +224,10 @@ public class VpcManagerImpl extends ManagerBase implements VpcManager, VpcProvis public static final String CAPABILITYVALUE = "capabilityvalue"; public static final String TRUE_VALUE = "true"; public static final String FALSE_VALUE = "false"; + protected static final String DEFAULT_ALLOW_ACL_NAME = "default_allow"; + protected static final String DEFAULT_DENY_ACL_NAME = "default_deny"; + protected static final String KUBERNETES_SERVICE_NETWORK_OFFERING_NAME_TOKEN = "KubernetesService"; + protected static final String KUBERNETES_CLUSTER_NETWORK_OFFERING_CONFIG_KEY = "cloud.kubernetes.cluster.network.offering"; @Inject EntityManager _entityMgr; @@ -2047,7 +2052,9 @@ private boolean checkAndUpdateRouterSourceNatIp(Vpc vpc, String sourceNatIp) { IPAddressVO requestedIp = validateSourceNatip(vpc, sourceNatIp); if (requestedIp == null) return false; // ip not associated with this network - List userIps = _ipAddressDao.listByAssociatedVpc(vpc.getId(), true); + List userIps = _ipAddressDao.listByAssociatedVpc(vpc.getId(), true).stream() + .filter(ip -> !ip.isForSystemVms()) + .collect(Collectors.toList()); if (! userIps.isEmpty()) { try { _ipAddrMgr.updateSourceNatIpAddress(requestedIp, userIps); @@ -3721,7 +3728,8 @@ public String getConfigComponentName() { public ConfigKey[] getConfigKeys() { return new ConfigKey[]{ VpcTierNamePrepend, - VpcTierNamePrependDelimiter + VpcTierNamePrependDelimiter, + VpcTierDefaultNetworkACL }; } @@ -3852,6 +3860,24 @@ public boolean isIpAllocatedToVpc(final IpAddress ip) { return ip != null && ip.getVpcId() != null && (ip.isOneToOneNat() || !_firewallDao.listByIp(ip.getId()).isEmpty()); } + /** + * Resolves the ACL a new VPC tier gets when the caller does not specify one. Kubernetes clusters + * cannot be provisioned on a tier that uses the default deny ACL, and default ACLs cannot be given + * rules, so tiers on the offerings the Kubernetes service deploys into always default to the + * default allow ACL; every other tier follows the VpcTierDefaultNetworkACL setting. + */ + protected Long getDefaultAclIdForNewTier(long ntwkOffId, long zoneId) { + NetworkOffering networkOffering = _entityMgr.findById(NetworkOffering.class, ntwkOffId); + String offeringName = networkOffering == null ? null : networkOffering.getName(); + if (org.apache.commons.lang3.StringUtils.isNotBlank(offeringName) + && (offeringName.contains(KUBERNETES_SERVICE_NETWORK_OFFERING_NAME_TOKEN) + || offeringName.equalsIgnoreCase(_configDao.getValue(KUBERNETES_CLUSTER_NETWORK_OFFERING_CONFIG_KEY)))) { + return NetworkACL.DEFAULT_ALLOW; + } + return DEFAULT_DENY_ACL_NAME.equalsIgnoreCase(VpcTierDefaultNetworkACL.valueIn(zoneId)) + ? NetworkACL.DEFAULT_DENY : NetworkACL.DEFAULT_ALLOW; + } + @DB @Override public Network createVpcGuestNetwork(final long ntwkOffId, final String name, final String displayText, final String gateway, final String cidr, final String vlanId, @@ -3880,12 +3906,19 @@ public Network createVpcGuestNetwork(final long ntwkOffId, final String name, fi // 1) Validate if network can be created for VPC validateNtwkOffForNtwkInVpc(null, ntwkOffId, cidr, networkDomain, vpc, gateway, owner, aclId); + Long effectiveAclId = aclId; + if (effectiveAclId == null && _ntwkModel.areServicesSupportedByNetworkOffering(ntwkOffId, Service.NetworkACL)) { + effectiveAclId = getDefaultAclIdForNewTier(ntwkOffId, zoneId); + logger.debug("No ACL provided for the new tier in VPC {}, defaulting to the {} ACL", vpc, + NetworkACL.DEFAULT_ALLOW == effectiveAclId ? DEFAULT_ALLOW_ACL_NAME : DEFAULT_DENY_ACL_NAME); + } + // 2) Create network final Network guestNetwork = _ntwkMgr.createGuestNetwork(ntwkOffId, name, displayText, gateway, cidr, vlanId, false, networkDomain, owner, domainId, pNtwk, zoneId, aclType, subdomainAccess, vpcId, ip6Gateway, ip6Cidr, isDisplayNetworkEnabled, null, null, externalId, null, null, ip4Dns1, ip4Dns2, ip6Dns1, ip6Dns2, vrIfaceMTUs, networkCidrSize); if (guestNetwork != null) { - guestNetwork.setNetworkACLId(aclId); + guestNetwork.setNetworkACLId(effectiveAclId); _ntwkDao.update(guestNetwork.getId(), (NetworkVO) guestNetwork); } return guestNetwork; 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..dd2a87d3edf9 100644 --- a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java @@ -24,6 +24,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import javax.inject.Inject; import javax.naming.ConfigurationException; @@ -62,6 +63,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 +78,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,6 +217,10 @@ public Site2SiteVpnGateway createVpnGateway(CreateVpnGatewayCmd cmd) { throw new InvalidParameterValueException(String.format("The VPN gateway of VPC %s already exists!", vpc)); } + if (getVpnServiceProvidersForVpc(vpcId).isEmpty()) { + 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)); + } + IPAddressVO requestedIp = _ipAddressDao.findById(cmd.getIpAddressId()); IPAddressVO ipAddress = getIpAddressIdForVpn(vpcId, vpc.getVpcOfferingId(), requestedIp); Site2SiteVpnGatewayVO gw = new Site2SiteVpnGatewayVO(owner.getAccountId(), owner.getDomainId(), ipAddress.getId(), vpcId); @@ -222,11 +229,46 @@ public Site2SiteVpnGateway createVpnGateway(CreateVpnGatewayCmd cmd) { gw.setDisplay(cmd.getDisplay()); } - _vpnGatewayDao.persist(gw); + try { + _vpnGatewayDao.persist(gw); + } catch (RuntimeException e) { + // give back the IP and provider-side VPN resources a provider may have prepared for this gateway + for (Site2SiteVpnServiceProvider provider : getVpnServiceProvidersForVpc(vpcId)) { + try { + provider.releaseVpnGatewayIp(gw); + } catch (Exception releaseException) { + logger.warn("Failed to release the VPN gateway IP of VPC {} after the VPN gateway could not be persisted: {}", + vpc, releaseException.getMessage()); + } + } + throw e; + } return gw; } + 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 IPAddressVO getIpAddressIdForVpn(Long vpcId, Long vpcOferingId, IPAddressVO requestedIp) { + // 1) A Site-to-Site VPN provider (e.g. an external provider terminating VPN on its own + // gateway) may supply a dedicated gateway IP instead of the VPC source NAT IP + Vpc vpcForProvider = _vpcDao.findById(vpcId); + for (Site2SiteVpnServiceProvider provider : getVpnServiceProvidersForVpc(vpcId)) { + IpAddress providerIp = provider.acquireVpnGatewayIp(vpcForProvider, requestedIp); + if (providerIp != null) { + logger.debug("Using VPN gateway IP {} supplied by provider {} for VPC {}", providerIp.getAddress(), provider.getName(), vpcForProvider); + return _ipAddressDao.findById(providerIp.getId()); + } + } + 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); if (mapForSourceNat == null && mapForVpn != null) { @@ -240,10 +282,17 @@ private IPAddressVO getIpAddressIdForVpn(Long vpcId, Long vpcOferingId, IPAddres } return ipAddressForVpcVR; } else { - //Use source NAT ip for VPC - List ips = _ipAddressDao.listByAssociatedVpc(vpcId, true); - if (ips.size() != 1) { - throw new CloudRuntimeException("Cannot found source nat ip of vpc " + vpcId); + // Use the VPC source NAT IP, excluding system-vm helper IPs: on NSX-backed VPCs the + // VR's public IP is also flagged source_nat (with forsystemvms=1) for redeploy idempotency + List ips = _ipAddressDao.listByAssociatedVpc(vpcId, true).stream() + .filter(ip -> !ip.isForSystemVms()) + .collect(Collectors.toList()); + if (ips.isEmpty()) { + throw new CloudRuntimeException(String.format("No source NAT IP found for VPC %s; Site-to-Site VPN requires the VPC source NAT IP", vpcId)); + } + if (ips.size() > 1) { + String addresses = ips.stream().map(ip -> ip.getAddress().addr()).collect(Collectors.joining(", ")); + throw new CloudRuntimeException(String.format("Multiple source NAT IPs (%s) found for VPC %s while exactly one was expected", addresses, vpcId)); } 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())); @@ -584,7 +633,7 @@ public Site2SiteVpnConnection startVpnConnection(long id) throws ResourceUnavail } boolean result = true; - for (Site2SiteVpnServiceProvider element : _s2sProviders) { + for (Site2SiteVpnServiceProvider element : getVpnServiceProvidersForVpc(vpnGateway.getVpcId())) { result = result & element.startSite2SiteVpn(conn); } @@ -643,6 +692,9 @@ 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)); } + for (Site2SiteVpnServiceProvider provider : getVpnServiceProvidersForVpc(gw.getVpcId())) { + provider.releaseVpnGatewayIp(gw); + } _vpnGatewayDao.remove(gw.getId()); } @@ -834,8 +886,9 @@ private void stopVpnConnection(Long id) throws ResourceUnavailableException { conn.setState(State.Disconnected); _vpnConnectionDao.persist(conn); + Site2SiteVpnGateway vpnGateway = _vpnGatewayDao.findById(conn.getVpnGatewayId()); boolean result = true; - for (Site2SiteVpnServiceProvider element : _s2sProviders) { + for (Site2SiteVpnServiceProvider element : getVpnServiceProvidersForVpc(vpnGateway.getVpcId())) { result = result & element.stopSite2SiteVpn(conn); } @@ -1067,6 +1120,12 @@ public List getConnectionsForRouter(DomainRouterVO rou if (router.getVpcId() == null) { return conns; } + // Only the connections the VPC VR actually terminates: when VPN is provided by an external + // provider the router knows nothing about them, and reporting on them would overwrite the + // state that provider reports + if (!vpcManager.isProviderSupportServiceInVpc(vpcId, Network.Service.Vpn, Network.Provider.VPCVirtualRouter)) { + return conns; + } conns.addAll(_vpnConnectionDao.listByVpcId(vpcId)); return conns; } diff --git a/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java b/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java index 2acad0c2b45d..3f9b8d97a2a3 100644 --- a/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java @@ -42,6 +42,7 @@ import com.cloud.network.dao.IPAddressDao; import com.cloud.network.dao.IPAddressVO; import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkVO; import com.cloud.network.element.NetworkElement; import com.cloud.network.element.VpcProvider; import com.cloud.network.router.CommandSetupHelper; @@ -76,6 +77,7 @@ import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.extension.ExtensionHelper; import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.network.Ipv4GuestSubnetNetworkMap; import org.apache.cloudstack.network.RoutedIpv4Manager; import org.junit.After; @@ -171,6 +173,8 @@ public class VpcManagerImplTest { NetworkACLVO networkACLVOMock; @Mock RoutedIpv4Manager routedIpv4Manager; + @Mock + ConfigurationDao configDao; public static final long ACCOUNT_ID = 1; private AccountVO account; @@ -231,6 +235,7 @@ public void setup() throws NoSuchFieldException, IllegalAccessException { manager._firewallDao = firewallDao; manager._networkAclDao = networkACLDaoMock; manager.routedIpv4Manager = routedIpv4Manager; + manager._configDao = configDao; CallContext.register(Mockito.mock(User.class), Mockito.mock(Account.class)); registerCallContext(); overrideDefaultConfigValue(NetworkService.AllowUsersToSpecifyVRMtu, "_defaultValue", "false"); @@ -400,6 +405,153 @@ public void testCreateVpcNetwork() throws InsufficientCapacityException, Resourc null, null, null, null, null, new Pair<>(1000, 1000), null); } + private NetworkVO prepareVpcGuestNetworkCreationMocks(Account accountMock, PhysicalNetwork physicalNetwork) + throws InsufficientCapacityException, ResourceAllocationException { + final long VPC_ID = 201L; + manager._maxNetworks = 3; + VpcVO vpcMockVO = Mockito.mock(VpcVO.class); + Vpc vpcMock = Mockito.mock(Vpc.class); + NetworkOffering offering = Mockito.mock(NetworkOffering.class); + List services = new ArrayList<>(); + services.add(Service.SourceNat); + + Mockito.lenient().when(vpcDao.getActiveVpcById(anyLong())).thenReturn(vpcMock); + Mockito.lenient().doNothing().when(accountManager).checkAccess(any(Account.class), nullable(SecurityChecker.AccessType.class), anyBoolean(), any(Vpc.class)); + Mockito.lenient().when(vpcMock.isRegionLevelVpc()).thenReturn(true); + Mockito.lenient().when(entityMgr.findById(NetworkOffering.class, 1L)).thenReturn(offering); + Mockito.lenient().when(vpcMock.getId()).thenReturn(VPC_ID); + Mockito.lenient().when(vpcDao.acquireInLockTable(VPC_ID)).thenReturn(vpcMockVO); + Mockito.lenient().when(networkDao.countVpcNetworks(anyLong())).thenReturn(1L); + Mockito.lenient().when(offering.getGuestType()).thenReturn(Network.GuestType.Isolated); + Mockito.lenient().when(networkModel.listNetworkOfferingServices(anyLong())).thenReturn(services); + Mockito.lenient().when(networkOfferingServiceMapDao.listByNetworkOfferingId(anyLong())).thenReturn(new ArrayList<>()); + Mockito.lenient().when(vpcMock.getCidr()).thenReturn("10.0.0.0/8"); + Mockito.lenient().when(vpcMock.getNetworkDomain()).thenReturn("cs1cloud.internal"); + + NetworkVO createdNetwork = Mockito.mock(NetworkVO.class); + Mockito.when(networkMgr.createGuestNetwork(1L, "vpcNet1", "vpc tier 1", null, + "10.10.10.0/24", null, false, "cs1cloud.internal", accountMock, null, + physicalNetwork, zoneId, null, null, 1L, null, null, + true, null, null, null, null, + null, null, null, null, null, new Pair<>(1000, 1000), null)).thenReturn(createdNetwork); + return createdNetwork; + } + + @Test + public void testCreateVpcNetworkDefaultsToAllowAclWhenOfferingSupportsNetworkAcl() throws InsufficientCapacityException, ResourceAllocationException { + Account accountMock = Mockito.mock(Account.class); + PhysicalNetwork physicalNetwork = Mockito.mock(PhysicalNetwork.class); + NetworkVO createdNetwork = prepareVpcGuestNetworkCreationMocks(accountMock, physicalNetwork); + Mockito.when(networkModel.areServicesSupportedByNetworkOffering(anyLong(), Mockito.eq(Service.NetworkACL))).thenReturn(true); + + manager.createVpcGuestNetwork(1L, "vpcNet1", "vpc tier 1", null, + "10.10.10.0/24", null, null, accountMock, null, physicalNetwork, + 1L, null, null, 1L, null, accountMock, + true, null, null, null, null, null, null, null, new Pair<>(1000, 1000), null); + + Mockito.verify(createdNetwork, times(1)).setNetworkACLId(NetworkACL.DEFAULT_ALLOW); + Mockito.verify(networkDao, times(1)).update(anyLong(), any(NetworkVO.class)); + } + + @Test + public void testGetDefaultAclIdForNewTierHonoursDenySetting() { + NetworkOffering offering = Mockito.mock(NetworkOffering.class); + Mockito.when(offering.getName()).thenReturn("Regular VPC tier offering"); + Mockito.when(entityMgr.findById(NetworkOffering.class, 1L)).thenReturn(offering); + overrideConfigKeyValue(VpcManager.VpcTierDefaultNetworkACL, "default_deny"); + try { + Assert.assertEquals(Long.valueOf(NetworkACL.DEFAULT_DENY), manager.getDefaultAclIdForNewTier(1L, zoneId)); + } finally { + resetConfigKeyValue(VpcManager.VpcTierDefaultNetworkACL); + } + } + + @Test + public void testGetDefaultAclIdForNewTierAlwaysAllowsForKubernetesOfferings() { + NetworkOffering offering = Mockito.mock(NetworkOffering.class); + Mockito.when(offering.getName()).thenReturn("DefaultNSXVPCNetworkOfferingforKubernetesService"); + Mockito.when(entityMgr.findById(NetworkOffering.class, 1L)).thenReturn(offering); + overrideConfigKeyValue(VpcManager.VpcTierDefaultNetworkACL, "default_deny"); + try { + Assert.assertEquals(Long.valueOf(NetworkACL.DEFAULT_ALLOW), manager.getDefaultAclIdForNewTier(1L, zoneId)); + } finally { + resetConfigKeyValue(VpcManager.VpcTierDefaultNetworkACL); + } + } + + @Test + public void testGetDefaultAclIdForNewTierAllowsForConfiguredKubernetesOffering() { + NetworkOffering offering = Mockito.mock(NetworkOffering.class); + Mockito.when(offering.getName()).thenReturn("MyCustomK8sTierOffering"); + Mockito.when(entityMgr.findById(NetworkOffering.class, 1L)).thenReturn(offering); + Mockito.when(configDao.getValue(VpcManagerImpl.KUBERNETES_CLUSTER_NETWORK_OFFERING_CONFIG_KEY)).thenReturn("MyCustomK8sTierOffering"); + overrideConfigKeyValue(VpcManager.VpcTierDefaultNetworkACL, "default_deny"); + try { + Assert.assertEquals(Long.valueOf(NetworkACL.DEFAULT_ALLOW), manager.getDefaultAclIdForNewTier(1L, zoneId)); + } finally { + resetConfigKeyValue(VpcManager.VpcTierDefaultNetworkACL); + } + } + + private void overrideConfigKeyValue(ConfigKey configKey, String value) { + try { + Field valueField = ConfigKey.class.getDeclaredField("_value"); + valueField.setAccessible(true); + valueField.set(configKey, value); + + Field dynamicField = ConfigKey.class.getDeclaredField("_isDynamic"); + dynamicField.setAccessible(true); + dynamicField.setBoolean(configKey, false); + } catch (IllegalAccessException | NoSuchFieldException e) { + throw new RuntimeException("Failed to set ConfigKey value", e); + } + } + + private void resetConfigKeyValue(ConfigKey configKey) { + try { + Field valueField = ConfigKey.class.getDeclaredField("_value"); + valueField.setAccessible(true); + valueField.set(configKey, null); + + Field dynamicField = ConfigKey.class.getDeclaredField("_isDynamic"); + dynamicField.setAccessible(true); + dynamicField.setBoolean(configKey, true); + } catch (IllegalAccessException | NoSuchFieldException e) { + throw new RuntimeException("Failed to reset ConfigKey value", e); + } + } + + @Test + public void testCreateVpcNetworkKeepsNullAclWhenOfferingLacksNetworkAclService() throws InsufficientCapacityException, ResourceAllocationException { + Account accountMock = Mockito.mock(Account.class); + PhysicalNetwork physicalNetwork = Mockito.mock(PhysicalNetwork.class); + NetworkVO createdNetwork = prepareVpcGuestNetworkCreationMocks(accountMock, physicalNetwork); + Mockito.when(networkModel.areServicesSupportedByNetworkOffering(anyLong(), Mockito.eq(Service.NetworkACL))).thenReturn(false); + + manager.createVpcGuestNetwork(1L, "vpcNet1", "vpc tier 1", null, + "10.10.10.0/24", null, null, accountMock, null, physicalNetwork, + 1L, null, null, 1L, null, accountMock, + true, null, null, null, null, null, null, null, new Pair<>(1000, 1000), null); + + Mockito.verify(createdNetwork, times(1)).setNetworkACLId((Long) null); + } + + @Test + public void testCreateVpcNetworkKeepsExplicitlyProvidedAcl() throws InsufficientCapacityException, ResourceAllocationException { + final Long explicitAclId = 5L; + Account accountMock = Mockito.mock(Account.class); + PhysicalNetwork physicalNetwork = Mockito.mock(PhysicalNetwork.class); + NetworkVO createdNetwork = prepareVpcGuestNetworkCreationMocks(accountMock, physicalNetwork); + Mockito.when(networkModel.areServicesSupportedByNetworkOffering(anyLong(), Mockito.eq(Service.NetworkACL))).thenReturn(true); + + manager.createVpcGuestNetwork(1L, "vpcNet1", "vpc tier 1", null, + "10.10.10.0/24", null, null, accountMock, null, physicalNetwork, + 1L, null, null, 1L, explicitAclId, accountMock, + true, null, null, null, null, null, null, null, new Pair<>(1000, 1000), null); + + Mockito.verify(createdNetwork, times(1)).setNetworkACLId(explicitAclId); + } + @Test public void testUpdateVpcNetwork() throws ResourceUnavailableException, InsufficientCapacityException { long vpcId = 1L; 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..6b3db7e07ece 100644 --- a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java @@ -20,6 +20,7 @@ import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.Network; import com.cloud.network.Site2SiteVpnConnection; import com.cloud.network.Site2SiteVpnConnection.State; import com.cloud.network.Site2SiteVpnGateway; @@ -31,6 +32,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.VpcManager; import com.cloud.network.vpc.VpcVO; @@ -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; @@ -59,6 +62,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; @@ -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.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyLong; @@ -114,6 +119,7 @@ public class Site2SiteVpnManagerImplTest { private AccountVO account; private UserVO user; private VpcVO vpc; + private Site2SiteVpnServiceProvider s2sProvider; private IPAddressVO ipAddress; private Site2SiteVpnGatewayVO vpnGateway; private Site2SiteCustomerGatewayVO customerGateway; @@ -145,6 +151,12 @@ public void setUp() throws Exception { when(ipAddress.getId()).thenReturn(IP_ADDRESS_ID); when(ipAddress.getVpcId()).thenReturn(VPC_ID); + s2sProvider = mock(Site2SiteVpnServiceProvider.class, + Mockito.withSettings().extraInterfaces(NetworkElement.class)); + when(((NetworkElement) s2sProvider).getProvider()).thenReturn(Network.Provider.VPCVirtualRouter); + when(_s2sProviders.iterator()).thenAnswer(invocation -> List.of(s2sProvider).iterator()); + when(_vpcMgr.isProviderSupportServiceInVpc(anyLong(), any(), any())).thenReturn(true); + vpnGateway = mock(Site2SiteVpnGatewayVO.class); when(vpnGateway.getId()).thenReturn(VPN_GATEWAY_ID); when(vpnGateway.getVpcId()).thenReturn(VPC_ID); @@ -262,6 +274,113 @@ public void testCreateVpnGatewayNoSourceNatIp() { site2SiteVpnManager.createVpnGateway(cmd); } + @Test + public void testCreateVpnGatewayReleasesProviderIpWhenPersistFails() { + CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); + when(cmd.getVpcId()).thenReturn(VPC_ID); + when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + + when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + when(s2sProvider.acquireVpnGatewayIp(any(), any())).thenReturn(ipAddress); + when(_ipAddressDao.findById(IP_ADDRESS_ID)).thenReturn(ipAddress); + CloudRuntimeException persistFailure = new CloudRuntimeException("persist failed"); + when(_vpnGatewayDao.persist(any(Site2SiteVpnGatewayVO.class))).thenThrow(persistFailure); + + try { + site2SiteVpnManager.createVpnGateway(cmd); + fail("The persist failure should have been rethrown"); + } catch (CloudRuntimeException e) { + assertEquals(persistFailure, e); + } + verify(s2sProvider).releaseVpnGatewayIp(any(Site2SiteVpnGateway.class)); + } + + @Test(expected = InvalidParameterValueException.class) + public void testCreateVpnGatewayVpnServiceNotSupported() { + CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); + when(cmd.getVpcId()).thenReturn(VPC_ID); + when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + + when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + when(_vpcMgr.isProviderSupportServiceInVpc(anyLong(), any(), any())).thenReturn(false); + + site2SiteVpnManager.createVpnGateway(cmd); + } + + @Test + public void testCreateVpnGatewaySkipsSystemVmSourceNatIp() { + CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); + when(cmd.getVpcId()).thenReturn(VPC_ID); + when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(cmd.isDisplay()).thenReturn(true); + + IPAddressVO systemVmIp = mock(IPAddressVO.class); + when(systemVmIp.isForSystemVms()).thenReturn(true); + + when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(List.of(systemVmIp, ipAddress)); + when(_vpnGatewayDao.persist(any(Site2SiteVpnGatewayVO.class))).thenReturn(vpnGateway); + + Site2SiteVpnGateway result = site2SiteVpnManager.createVpnGateway(cmd); + + assertNotNull(result); + ArgumentCaptor gatewayCaptor = ArgumentCaptor.forClass(Site2SiteVpnGatewayVO.class); + verify(_vpnGatewayDao).persist(gatewayCaptor.capture()); + assertEquals(IP_ADDRESS_ID.longValue(), gatewayCaptor.getValue().getAddrId()); + } + + @Test + public void testCreateVpnGatewayUsesProviderAcquiredIp() { + CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); + when(cmd.getVpcId()).thenReturn(VPC_ID); + when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(cmd.isDisplay()).thenReturn(true); + + when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + when(s2sProvider.acquireVpnGatewayIp(any(), any())).thenReturn(ipAddress); + when(_ipAddressDao.findById(IP_ADDRESS_ID)).thenReturn(ipAddress); + when(_vpnGatewayDao.persist(any(Site2SiteVpnGatewayVO.class))).thenReturn(vpnGateway); + + Site2SiteVpnGateway result = site2SiteVpnManager.createVpnGateway(cmd); + + assertNotNull(result); + ArgumentCaptor gatewayCaptor = ArgumentCaptor.forClass(Site2SiteVpnGatewayVO.class); + verify(_vpnGatewayDao).persist(gatewayCaptor.capture()); + assertEquals(IP_ADDRESS_ID.longValue(), gatewayCaptor.getValue().getAddrId()); + verify(_ipAddressDao, never()).listByAssociatedVpc(anyLong(), anyBoolean()); + } + + @Test + public void testDoDeleteVpnGatewayReleasesProviderIp() { + when(_vpnConnectionDao.listByVpnGatewayId(VPN_GATEWAY_ID)).thenReturn(new ArrayList<>()); + + site2SiteVpnManager.doDeleteVpnGateway(vpnGateway); + + verify(s2sProvider).releaseVpnGatewayIp(vpnGateway); + verify(_vpnGatewayDao).remove(VPN_GATEWAY_ID); + } + + @Test(expected = CloudRuntimeException.class) + public void testCreateVpnGatewayMultipleSourceNatIps() { + CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); + when(cmd.getVpcId()).thenReturn(VPC_ID); + when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + + IPAddressVO secondIp = mock(IPAddressVO.class); + when(ipAddress.getAddress()).thenReturn(new Ip("203.0.113.34")); + when(secondIp.getAddress()).thenReturn(new Ip("203.0.113.35")); + + when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(List.of(ipAddress, secondIp)); + + site2SiteVpnManager.createVpnGateway(cmd); + } + @Test(expected = InvalidParameterValueException.class) public void testCreateCustomerGatewayInvalidIp() { CreateVpnCustomerGatewayCmd cmd = mock(CreateVpnCustomerGatewayCmd.class); diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 775de26103a0..d7513c9f2c13 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -3968,6 +3968,9 @@ "message.remove.sticky.policy.failed": "Failed to remove sticky policy.", "message.remove.sticky.policy.processing": "Removing sticky policy...", "message.remove.vpc": "Please confirm that you want to remove the VPC", +"message.replace.acl.failed": "Failed to replace the Network ACL list", +"message.replace.acl.processing": "Replacing the Network ACL list...", +"message.replace.acl.success": "Successfully replaced the Network ACL list", "message.request.failed": "Request failed.", "message.request.no.data": "There is no data to show.", "message.required.add.least.ip": "Please add at least 1 IP Range", diff --git a/ui/src/views/network/VpcTiersTab.vue b/ui/src/views/network/VpcTiersTab.vue index 4a689f13c34d..92a9f8b83142 100644 --- a/ui/src/views/network/VpcTiersTab.vue +++ b/ui/src/views/network/VpcTiersTab.vue @@ -56,6 +56,12 @@ {{ network.aclname }} + @@ -302,6 +308,56 @@ + + + +

{{ $t('message.confirm.replace.acl.new.one') }}

+ + + + + {{ item.name }} ({{ item.description }}) + + + + + + + + + +
+ {{ $t('label.cancel') }} + {{ $t('label.ok') }} +
+
+
+
+ { this.networkAclList = json.listnetworkacllistsresponse.networkacllist || [] - this.handleNetworkAclChange(null) + this.form.acl = selectedAclId + this.handleNetworkAclChange(selectedAclId) }).catch(error => { this.$notifyError(error) }).finally(() => { @@ -695,7 +755,7 @@ export default { }, handleNetworkAclChange (aclId) { if (aclId) { - this.selectedNetworkAcl = this.networkAclList.filter(acl => acl.id === aclId)[0] + this.selectedNetworkAcl = this.networkAclList.filter(acl => acl.id === aclId)[0] || {} } else { this.selectedNetworkAcl = {} } @@ -724,6 +784,51 @@ export default { vlan: [{ required: true, message: this.$t('message.please.enter.value') }] } }, + handleOpenReplaceAclModal (network) { + this.initForm() + this.networkid = network.id + this.fetchNetworkAclList(network.aclid) + this.showReplaceAclModal = true + this.rules = { + acl: [{ required: true, message: this.$t('label.required') }] + } + }, + handleReplaceAclSubmit () { + if (this.modalLoading) return + + this.formRef.value.validate().then(() => { + const values = this.handleRemoveFields(toRaw(this.form)) + + this.fetchLoading = true + this.modalLoading = true + this.showReplaceAclModal = false + + postAPI('replaceNetworkACLList', { + aclid: values.acl, + networkid: this.networkid + }).then(response => { + this.$pollJob({ + jobId: response.replacenetworkacllistresponse.jobid, + title: this.$t('label.replace.acl'), + description: this.networkid, + successMessage: this.$t('message.replace.acl.success'), + successMethod: () => { + this.parentFetchData() + }, + errorMessage: this.$t('message.replace.acl.failed'), + loadingMessage: this.$t('message.replace.acl.processing'), + catchMessage: this.$t('error.fetching.async.job.result') + }) + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.fetchLoading = false + this.modalLoading = false + }) + }).catch((error) => { + this.formRef.value.scrollToField(error.errorFields[0].name) + }) + }, handleAddInternalLB (id) { this.initForm() this.showAddInternalLB = true From 8ea6ca3eed75be581187a212c0d600bf59e32fdb 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 02/30] NSX: add native route-based site-to-site VPN support --- .../element/Site2SiteVpnServiceProvider.java | 22 + .../com/cloud/network/nsx/NsxService.java | 12 + .../network/nsx/NsxVpnGatewayResult.java | 36 ++ .../java/org/apache/cloudstack/NsxAnswer.java | 9 + .../UpdateNsxVpnConnectionStateCommand.java | 72 +++ .../cloudstack/resource/NsxResource.java | 178 ++++-- .../cloudstack/service/NsxApiClient.java | 517 ++++++++++++++---- .../apache/cloudstack/service/NsxElement.java | 179 +++++- .../cloudstack/service/NsxServiceImpl.java | 109 ++-- .../cloudstack/utils/NsxControllerUtils.java | 11 +- .../apache/cloudstack/utils/NsxHelper.java | 24 +- .../cloudstack/resource/NsxResourceTest.java | 134 +++++ .../cloudstack/service/NsxApiClientTest.java | 255 +++++++++ .../cloudstack/service/NsxElementTest.java | 276 +++++++++- .../service/NsxServiceImplTest.java | 325 +++-------- .../utils/NsxControllerUtilsTest.java | 25 + .../cloudstack/utils/NsxHelperTest.java | 35 -- .../network/vpn/Site2SiteVpnManagerImpl.java | 214 ++++++-- .../vpn/Site2SiteVpnManagerImplTest.java | 256 ++++++--- 19 files changed, 2011 insertions(+), 678 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/UpdateNsxVpnConnectionStateCommand.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 32055ba2aa81..40829a5c5a7a 100644 --- a/api/src/main/java/com/cloud/network/element/Site2SiteVpnServiceProvider.java +++ b/api/src/main/java/com/cloud/network/element/Site2SiteVpnServiceProvider.java @@ -18,16 +18,29 @@ 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) @@ -46,4 +59,13 @@ default IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { */ 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/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 13093cad0d71..530c4181473c 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 @@ -54,9 +54,9 @@ 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.cloudstack.utils.NsxHelper; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; @@ -71,6 +71,17 @@ 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"; @@ -151,6 +162,8 @@ public Answer executeRequest(Command cmd) { 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); } @@ -323,9 +336,6 @@ private Answer executeRequest(CheckHealthCommand cmd) { private Answer executeRequest(CreateNsxTier1GatewayCommand cmd) { String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), cmd.getZoneId(), cmd.getNetworkResourceId(), cmd.isResourceVpc()); boolean sourceNatEnabled = cmd.isSourceNatEnabled(); - // The command carries a Tier-0 and edge cluster only when the tenant has an NSX - // VRF gateway registered. Otherwise fall back to the zone-wide values this - // resource was configured with, so non-VRF zones behave exactly as before. String targetTier0 = StringUtils.defaultIfBlank(cmd.getTier0Gateway(), tier0Gateway); String targetEdgeCluster = StringUtils.defaultIfBlank(cmd.getEdgeCluster(), edgeCluster); if (!targetTier0.equals(tier0Gateway) || !targetEdgeCluster.equals(edgeCluster)) { @@ -346,11 +356,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); } @@ -519,25 +532,58 @@ private NsxAnswer executeRequest(DeleteNsxDistributedFirewallRulesCommand cmd) { private NsxAnswer executeRequest(CreateNsxVpnGatewayCommand cmd) { String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), cmd.getZoneId(), cmd.getVpcId(), true); - try { - nsxApiClient.createVpnService(tier1GatewayName, cmd.getLocalEndpointIp()); - } catch (Exception e) { - 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())); - return new NsxAnswer(cmd, new CloudRuntimeException(e.getMessage())); + 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; + } } - return new NsxAnswer(cmd, true, null); + 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); - 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())); + 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); } @@ -545,23 +591,42 @@ private NsxAnswer executeRequest(DeleteNsxVpnGatewayCommand cmd) { private NsxAnswer executeRequest(CreateNsxVpnConnectionCommand cmd) { String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), cmd.getZoneId(), cmd.getVpcId(), true); - try { - // The requested VTI /30 is derived from the connection id and may collide with another - // session on the same tier-1: probe from it to the first free slot - Set inUseVtiIps = nsxApiClient.getRouteBasedVpnSessionLocalVtiIps(tier1GatewayName, cmd.getConnectionUuid()); - Pair vtiAddresses = NsxHelper.findFreeVpnVtiAddressPair(cmd.getVtiLocalIp(), inUseVtiIps); - 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()); - 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) { - 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())); + 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); } @@ -569,16 +634,39 @@ private NsxAnswer executeRequest(CreateNsxVpnConnectionCommand cmd) { private NsxAnswer executeRequest(DeleteNsxVpnConnectionCommand cmd) { String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), cmd.getZoneId(), cmd.getVpcId(), true); - 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())); + 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); 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 554620101418..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 @@ -63,6 +63,7 @@ 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; @@ -93,6 +94,7 @@ 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; @@ -190,6 +192,8 @@ public class NsxApiClient { // 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"; @@ -289,15 +293,11 @@ public boolean isNsxControllerActive() { public void createTier1NatRule(String tier1GatewayName, String natId, String natRuleId, String action, String translatedIp) { NatRules natRulesService = (NatRules) nsxService.apply(NatRules.class); - PolicyNatRule.Builder natPolicyBuilder = new PolicyNatRule.Builder() + PolicyNatRule natPolicy = new PolicyNatRule.Builder() .setAction(action) - .setTranslatedNetwork(translatedIp); - if (NatAction.SNAT.name().equals(action)) { - // The source NAT rule matches any source, so it has to be evaluated after the rules that - // exempt specific traffic from it, such as the ones site-to-site VPN adds - natPolicyBuilder.setSequenceNumber(CATCH_ALL_NAT_SEQUENCE_NUMBER); - } - natRulesService.patch(tier1GatewayName, natId, natRuleId, natPolicyBuilder.build()); + .setTranslatedNetwork(translatedIp) + .build(); + natRulesService.patch(tier1GatewayName, natId, natRuleId, natPolicy); } /** @@ -321,52 +321,123 @@ public void ensureVpnNatExemptions(String tier1GatewayName, String localEndpoint natService.patch(tier1GatewayName, NatId.USER.name(), localEndpointNoSnatRuleName, localEndpointNoSnatRule); } - /** - * Existing tier-1 gateways carry a source NAT rule created without a sequence number, which leaves - * its precedence against the no-SNAT rules undefined. Push it behind them so traffic the VPN - * exempts, including the IKE traffic the gateway itself originates, is never translated. - */ + /** 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 { - List natRules = PagedFetcher.withPageFetcher( - cursor -> natRulesService.list(tier1GatewayName, NatId.USER.name(), cursor, false, null, 50L, false, null)) - .cursorExtractor(PolicyNatRuleListResult::getCursor) - .itemsExtractor(PolicyNatRuleListResult::getResults) - .itemsSetter((page, allItems) -> { - page.setResults(allItems); - page.setResultCount((long) allItems.size()); - }) - .fetchAll().getResults(); - for (PolicyNatRule natRule : natRules) { - if (!NatAction.SNAT.name().equals(natRule.getAction()) - || (natRule.getSequenceNumber() != null && natRule.getSequenceNumber() >= CATCH_ALL_NAT_SEQUENCE_NUMBER)) { - continue; + 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)); } - logger.debug("Moving the source NAT rule {} on tier-1 gateway {} behind the VPN no-SNAT rules", - natRule.getId(), tier1GatewayName); - // NSX validates the whole rule on a patch, so the existing definition has to be sent - // back with it rather than the sequence number on its own - PolicyNatRule.Builder demotedRule = new PolicyNatRule.Builder() - .setId(natRule.getId()) - .setDisplayName(natRule.getDisplayName()) - .setAction(natRule.getAction()) - .setTranslatedNetwork(natRule.getTranslatedNetwork()) - .setSourceNetwork(natRule.getSourceNetwork()) - .setDestinationNetwork(natRule.getDestinationNetwork()) - .setService(natRule.getService()) - .setFirewallMatch(natRule.getFirewallMatch()) - .setEnabled(natRule.getEnabled()) - .setSequenceNumber(CATCH_ALL_NAT_SEQUENCE_NUMBER); - natRulesService.patch(tier1GatewayName, NatId.USER.name(), natRule.getId(), demotedRule.build()); + 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); - logger.warn("Could not reorder the source NAT rules of tier-1 gateway {}, VPN traffic may be translated: {}", - tier1GatewayName, ae.getErrorMessage()); + 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); @@ -1395,6 +1466,24 @@ public void createVpnService(String tier1GatewayName, String localEndpointIp) { } } + /** + * 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 @@ -1423,54 +1512,14 @@ private void ensureTier1IpsecLocalEndpointAdvertisement(String tier1GatewayName) } public void deleteVpnService(String tier1GatewayName) { - String vpnServiceName = getVpnServiceName(tier1GatewayName); - String localEndpointName = getVpnLocalEndpointName(vpnServiceName); 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; } - String localEndpointNoSnatRuleName = getVpnLocalEndpointNoSnatRuleName(vpnServiceName); - try { - NatRules natService = (NatRules) nsxService.apply(NatRules.class); - natService.delete(tier1GatewayName, NatId.USER.name(), localEndpointNoSnatRuleName); - } catch (NotFound e) { - logger.debug("The no-SNAT rule {} on tier-1 gateway {} no longer exists, skipping deletion", - localEndpointNoSnatRuleName, tier1GatewayName); - } catch (Error error) { - ApiError ae = error.getData()._convertTo(ApiError.class); - String msg = String.format("Failed to delete the no-SNAT rule %s of the NSX IPSec VPN service on tier-1 gateway %s, due to: %s", - localEndpointNoSnatRuleName, tier1GatewayName, ae.getErrorMessage()); - logger.error(msg); - throw new CloudRuntimeException(msg); - } - try { - LocalEndpoints localEndpoints = (LocalEndpoints) nsxService.apply(LocalEndpoints.class); - localEndpoints.delete(tier1GatewayName, vpnServiceName, localEndpointName); - } catch (NotFound e) { - logger.debug("The local endpoint {} of the VPN service {} on tier-1 gateway {} no longer exists, skipping deletion", - localEndpointName, vpnServiceName, tier1GatewayName); - } catch (Error error) { - ApiError ae = error.getData()._convertTo(ApiError.class); - String msg = String.format("Failed to delete the local endpoint %s of the NSX IPSec VPN service %s on tier-1 gateway %s, due to: %s", - localEndpointName, vpnServiceName, tier1GatewayName, ae.getErrorMessage()); - logger.error(msg); - throw new CloudRuntimeException(msg); - } - try { - IpsecVpnServices vpnServices = (IpsecVpnServices) nsxService.apply(IpsecVpnServices.class); - vpnServices.delete(tier1GatewayName, vpnServiceName); - } catch (NotFound e) { - logger.debug("The VPN service {} on tier-1 gateway {} no longer exists, skipping deletion", - vpnServiceName, tier1GatewayName); - } catch (Error error) { - ApiError ae = error.getData()._convertTo(ApiError.class); - String msg = String.format("Failed to delete NSX IPSec VPN service %s on tier-1 gateway %s, due to: %s", - vpnServiceName, tier1GatewayName, ae.getErrorMessage()); - logger.error(msg); - throw new CloudRuntimeException(msg); - } + removeTier1VpnResources(tier1GatewayName); + restoreSourceNatRuleSequence(tier1GatewayName); } public void createRouteBasedVpnSession(String tier1GatewayName, String connectionUuid, String peerAddress, @@ -1486,6 +1535,10 @@ public void createRouteBasedVpnSession(String tier1GatewayName, String connectio 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 {}/{})", @@ -1516,7 +1569,16 @@ private void doCreateRouteBasedVpnSession(String tier1GatewayName, String connec String dpdProfileName = getVpnDpdProfileName(connectionUuid); try { IpsecVpnIkeProfiles ikeProfiles = (IpsecVpnIkeProfiles) nsxService.apply(IpsecVpnIkeProfiles.class); - IPSecVpnIkeProfile ikeProfile = new IPSecVpnIkeProfile.Builder() + 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)) @@ -1538,10 +1600,8 @@ private void doCreateRouteBasedVpnSession(String tier1GatewayName, String connec if (!espDhGroups.isEmpty()) { espProfileBuilder.setDhGroups(espDhGroups); } - IpsecVpnTunnelProfiles espProfiles = (IpsecVpnTunnelProfiles) nsxService.apply(IpsecVpnTunnelProfiles.class); espProfiles.patch(espProfileName, espProfileBuilder.build()); - IpsecVpnDpdProfiles dpdProfiles = (IpsecVpnDpdProfiles) nsxService.apply(IpsecVpnDpdProfiles.class); // 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 @@ -1579,8 +1639,18 @@ private void doCreateRouteBasedVpnSession(String tier1GatewayName, String connec .setLocalEndpointPath(getVpnLocalEndpointPath(tier1GatewayName, vpnServiceName, localEndpointName)) .setTunnelInterfaces(List.of(tunnelInterface)) .build(); - Sessions sessions = (Sessions) nsxService.apply(Sessions.class); - sessions.patch(tier1GatewayName, vpnServiceName, sessionName, session); + 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", @@ -1590,9 +1660,98 @@ private void doCreateRouteBasedVpnSession(String tier1GatewayName, String connec } } + 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); @@ -1631,12 +1790,23 @@ public void addVpnConnectionRoutes(String tier1GatewayName, String connectionUui } public void deleteVpnConnection(String tier1GatewayName, String connectionUuid) { - String vpnServiceName = getVpnServiceName(tier1GatewayName); - String sessionName = getVpnSessionName(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 - deleteVpnStaticRoutesByPrefix(tier1GatewayName, getVpnStaticRouteNamePrefix(connectionUuid)); - deleteVpnNoSnatRulesByPrefix(tier1GatewayName, getVpnNoSnatRuleNamePrefix(connectionUuid)); + 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); @@ -1650,10 +1820,39 @@ public void deleteVpnConnection(String tier1GatewayName, String connectionUuid) logger.error(msg); throw new CloudRuntimeException(msg); } - // The IKE/tunnel/DPD profiles are deliberately left in place: NSX marks deleted objects for - // deletion asynchronously, so recreating them under the same path on a connection restart - // fails until the reaper has run. They are reused (patched) when the session is recreated - // and swept with the rest of the VPN objects when the tier-1 gateway is removed. + } + + 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) { @@ -1727,6 +1926,17 @@ private void deleteVpnNoSnatRulesByPrefix(String tier1GatewayName, String ruleNa } 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)); @@ -1739,6 +1949,9 @@ private void deleteVpnSessionProfiles(String connectionUuid) { logger.error(msg); throw new CloudRuntimeException(msg); } + } + + private void deleteVpnTunnelProfile(String connectionUuid) { try { IpsecVpnTunnelProfiles espProfiles = (IpsecVpnTunnelProfiles) nsxService.apply(IpsecVpnTunnelProfiles.class); espProfiles.delete(getVpnEspProfileName(connectionUuid)); @@ -1751,6 +1964,9 @@ private void deleteVpnSessionProfiles(String connectionUuid) { logger.error(msg); throw new CloudRuntimeException(msg); } + } + + private void deleteVpnDpdProfile(String connectionUuid) { try { IpsecVpnDpdProfiles dpdProfiles = (IpsecVpnDpdProfiles) nsxService.apply(IpsecVpnDpdProfiles.class); dpdProfiles.delete(getVpnDpdProfileName(connectionUuid)); @@ -1765,6 +1981,32 @@ private void deleteVpnSessionProfiles(String connectionUuid) { } } + 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); @@ -1807,19 +2049,41 @@ public String getVpnSessionStatus(String tier1GatewayName, String connectionUuid */ private void removeTier1VpnResources(String tier1Id) { deleteVpnStaticRoutesByPrefix(tier1Id, getVpnSessionName("")); + deleteVpnNoSnatRulesByPrefix(tier1Id, getVpnSessionName("")); + deleteVpnLocalEndpointNoSnatRule(tier1Id); try { IpsecVpnServices vpnServices = (IpsecVpnServices) nsxService.apply(IpsecVpnServices.class); - List services = vpnServices.list(tier1Id, null, false, null, null, false, null).getResults(); + 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) { - IPSecVpnSessionListResult sessionList = sessions.list(tier1Id, service.getId(), null, false, null, null, false, null); - if (CollectionUtils.isNotEmpty(sessionList.getResults())) { + 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 : sessionList.getResults()) { + 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()); @@ -1828,9 +2092,17 @@ private void removeTier1VpnResources(String tier1Id) { } } } - IPSecVpnLocalEndpointListResult localEndpointList = localEndpoints.list(tier1Id, service.getId(), null, false, null, null, false, null); - if (CollectionUtils.isNotEmpty(localEndpointList.getResults())) { - for (IPSecVpnLocalEndpoint localEndpoint : localEndpointList.getResults()) { + 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()); } @@ -1849,9 +2121,25 @@ private void removeTier1VpnResources(String tier1Id) { } } + 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 probe for a collision-free VTI /30 + * 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); @@ -1859,11 +2147,16 @@ public Set getRouteBasedVpnSessionLocalVtiIps(String tier1GatewayName, S Set vtiIps = new HashSet<>(); try { Sessions sessions = (Sessions) nsxService.apply(Sessions.class); - IPSecVpnSessionListResult sessionList = sessions.list(tier1GatewayName, vpnServiceName, null, false, null, null, false, null); - if (CollectionUtils.isEmpty(sessionList.getResults())) { - return vtiIps; - } - for (Structure result : sessionList.getResults()) { + 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())) { 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 8ce690925932..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 @@ -47,6 +47,7 @@ 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; @@ -54,6 +55,7 @@ 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; @@ -78,15 +80,18 @@ 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; @@ -185,6 +190,8 @@ public class NsxElement extends AdapterBase implements DhcpServiceProvider, Dns @Inject LoadBalancerVMMapDao lbVmMapDao; @Inject + LoadBalancerDao loadBalancerDao; + @Inject VirtualRouterProviderDao vrProviderDao; @Inject PhysicalNetworkServiceProviderDao pNtwkSvcProviderDao; @@ -195,6 +202,8 @@ public class NsxElement extends AdapterBase implements DhcpServiceProvider, Dns @Inject VpcService vpcService; @Inject + VpcManager vpcManager; + @Inject Site2SiteVpnGatewayDao vpnGatewayDao; @Inject Site2SiteCustomerGatewayDao customerGatewayDao; @@ -202,6 +211,8 @@ public class NsxElement extends AdapterBase implements DhcpServiceProvider, Dns UserIpAddressDetailsDao userIpAddressDetailsDao; @Inject FirewallRulesDao firewallRulesDao; + @Inject + PortForwardingRulesDao portForwardingRulesDao; protected Logger logger = LogManager.getLogger(getClass()); @@ -978,10 +989,20 @@ public boolean updateVpcSourceNatIp(Vpc vpc, IpAddress address) { } protected boolean isVpnProvidedByNsx(Vpc vpc) { - return Objects.nonNull(vpc) && Objects.nonNull(vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId( + 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)) { @@ -995,11 +1016,42 @@ public IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { 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 { - nsxService.createVpnGateway(vpc, ip.getAddress().addr()); + 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) { - releaseAutoAcquiredVpnGatewayIp(ip); + 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); @@ -1009,15 +1061,19 @@ public IpAddress acquireVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { private IPAddressVO validateRequestedVpnGatewayIp(Vpc vpc, IpAddress requestedIp) { IPAddressVO ip = ipAddressDao.findById(requestedIp.getId()); - if (Objects.isNull(ip) || !Objects.equals(ip.getVpcId(), vpc.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 %s is not associated to the VPC %s", requestedIp.getAddress().addr(), vpc.getName())); + "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.countRulesByIpId(ip.getId()) > 0) { + 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())); } @@ -1033,7 +1089,17 @@ private IPAddressVO allocateVpnGatewayIp(Vpc vpc) { CallContext.current().getCallingUser(), zone, null, null); vpcService.associateIPToVpc(allocatedIp.getId(), vpc.getId()); userIpAddressDetailsDao.addDetail(allocatedIp.getId(), NSX_VPN_GATEWAY_IP_DETAIL, "true", false); - return ipAddressDao.findById(allocatedIp.getId()); + 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)) { @@ -1045,6 +1111,14 @@ private IPAddressVO allocateVpnGatewayIp(Vpc vpc) { 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", @@ -1053,31 +1127,66 @@ private IPAddressVO allocateVpnGatewayIp(Vpc vpc) { } 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); - ipAddressManager.disassociatePublicIpAddress(ip, CallContext.current().getCallingUserId(), CallContext.current().getCallingAccount()); } @Override public void releaseVpnGatewayIp(Site2SiteVpnGateway gateway) { VpcVO vpc = vpcDao.findById(gateway.getVpcId()); - if (!isVpnProvidedByNsx(vpc)) { - return; - } - try { - nsxService.deleteVpnGateway(vpc); - } catch (Exception e) { - throw new CloudRuntimeException(String.format("Failed to delete the NSX VPN gateway of VPC %s: %s", - vpc.getName(), e.getMessage()), e); + 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.nonNull(autoAcquiredDetail) && Boolean.parseBoolean(autoAcquiredDetail.getValue())) { - logger.debug("Releasing the auto-acquired VPN gateway IP {} of VPC {}", ip.getAddress().addr(), vpc.getName()); + 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 @@ -1088,7 +1197,12 @@ public boolean startSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUna "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 (!isVpnProvidedByNsx(vpc)) { + 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()); @@ -1096,9 +1210,7 @@ public boolean startSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUna throw new CloudRuntimeException(String.format( "Cannot find the customer gateway %s of the Site-to-Site VPN connection %s", conn.getCustomerGatewayId(), conn.getUuid())); } - NsxVpnCryptoUtils.validate(customerGateway.getIkePolicy(), customerGateway.getEspPolicy(), - customerGateway.getIkeVersion(), customerGateway.getIkeLifetime(), customerGateway.getEspLifetime(), - customerGateway.getIpsecPsk()); + validateSite2SiteVpnCustomerGateway(customerGateway); if (BooleanUtils.isTrue(customerGateway.getEncap())) { logger.debug("Ignoring forceencap for the NSX VPN connection {}: NSX negotiates NAT-T automatically", conn); } @@ -1130,7 +1242,26 @@ public boolean stopSite2SiteVpn(Site2SiteVpnConnection conn) throws ResourceUnav "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 (!isVpnProvidedByNsx(vpc)) { + 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 31aaceff4183..ba5b4988b5d4 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 @@ -46,36 +46,40 @@ 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.domain.DomainVO; +import com.cloud.domain.dao.DomainDao; 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.NsxVrfGatewayDao; 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.element.NsxVrfGatewayVO; 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.domain.DomainVO; -import com.cloud.domain.dao.DomainDao; -import com.cloud.network.dao.NsxVrfGatewayDao; -import com.cloud.network.element.NsxVrfGatewayVO; import com.cloud.utils.exception.CloudRuntimeException; import org.apache.commons.lang3.StringUtils; @@ -89,17 +93,14 @@ public class NsxServiceImpl extends ManagerBase implements NsxService, Configura public static final ConfigKey NSX_VRF_SCOPE = new ConfigKey<>(String.class, "nsx.vrf.scope", "Advanced", "NONE", "Routing domain granularity for NSX zones. NONE (default) attaches every tenant's Tier-1 " + - "gateways to the zone-wide Tier-0, which is the behaviour before per-tenant VRFs existed. " + - "ACCOUNT or DOMAIN instead attaches them to the VRF (or dedicated) Tier-0 registered for " + - "that tenant, giving each tenant its own routing table", + "gateways to the zone-wide Tier-0. ACCOUNT or DOMAIN attaches them to the registered " + + "tenant-specific Tier-0 or VRF gateway", true, ConfigKey.Scope.Zone, null, null, null, null, null, ConfigKey.Kind.Select, "NONE,ACCOUNT,DOMAIN"); public static final ConfigKey NSX_VRF_FALLBACK_TO_SHARED_TIER0 = new ConfigKey<>("Advanced", Boolean.class, "nsx.vrf.fallback.to.shared.tier0", "false", - "Only consulted when nsx.vrf.scope is not NONE. When false, creating a network or VPC for a tenant " + - "with no NSX VRF gateway registered fails. When true, such tenants silently fall back to the " + - "zone-wide Tier-0 — convenient, but it puts them back in the shared routing table", + "Allow a tenant without an assigned NSX VRF gateway to use the zone-wide Tier-0 when nsx.vrf.scope is enabled", true, ConfigKey.Scope.Zone); protected static final String NSX_VRF_SCOPE_NONE = "NONE"; @@ -113,7 +114,7 @@ public class NsxServiceImpl extends ManagerBase implements NsxService, Configura protected static final int VPN_STATUS_POLL_DEFAULT_INTERVAL = 60; private static final List VPN_POLLED_STATES = List.of( - Site2SiteVpnConnection.State.Connecting, Site2SiteVpnConnection.State.Connected, + Site2SiteVpnConnection.State.Pending, Site2SiteVpnConnection.State.Connecting, Site2SiteVpnConnection.State.Connected, Site2SiteVpnConnection.State.Disconnected); @Inject @@ -123,15 +124,19 @@ public class NsxServiceImpl extends ManagerBase implements NsxService, Configura @Inject VpcOfferingServiceMapDao vpcOfferingServiceMapDao; @Inject + VpcManager vpcManager; + @Inject Site2SiteVpnConnectionDao site2SiteVpnConnectionDao; @Inject Site2SiteVpnGatewayDao site2SiteVpnGatewayDao; @Inject - AlertManager alertManager; - @Inject NsxVrfGatewayDao nsxVrfGatewayDao; @Inject DomainDao domainDao; + @Inject + UserIpAddressDetailsDao userIpAddressDetailsDao; + @Inject + AlertManager alertManager; protected Logger logger = LogManager.getLogger(getClass()); @@ -140,12 +145,17 @@ public class NsxServiceImpl extends ManagerBase implements NsxService, Configura @Override public boolean configure(String name, Map params) throws ConfigurationException { + super.configure(name, params); vpnStatusPollExecutor = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("Nsx-Vpn-Status-Poll")); return true; } @Override public boolean start() { + 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) { @@ -162,7 +172,7 @@ public boolean stop() { if (Objects.nonNull(vpnStatusPollExecutor)) { vpnStatusPollExecutor.shutdownNow(); } - return true; + return super.stop(); } public boolean createVpcNetwork(Long zoneId, long accountId, long domainId, Long vpcId, String vpcName, boolean sourceNatEnabled) { @@ -173,11 +183,6 @@ public boolean createVpcNetwork(Long zoneId, long accountId, long domainId, Long return result.getResult(); } - /** - * Points the new Tier-1 gateway at the tenant's own Tier-0 and edge cluster, when the - * zone is VRF-segregated and the tenant has one registered. Leaves the command - * untouched otherwise, so the resource falls back to the zone-wide values. - */ protected void applyVrfGateway(CreateNsxTier1GatewayCommand cmd, Long zoneId, long accountId, long domainId) { NsxVrfGatewayVO gateway = resolveVrfGateway(zoneId, accountId, domainId); if (gateway == null) { @@ -187,19 +192,6 @@ protected void applyVrfGateway(CreateNsxTier1GatewayCommand cmd, Long zoneId, lo cmd.setEdgeCluster(gateway.getEdgeCluster()); } - /** - * Finds the NSX VRF gateway a tenant's Tier-1 gateways should attach to. - * - * Resolution order is exact account, then the tenant's domain, then each ancestor - * domain in turn — so a reseller can register one routing domain per customer rather - * than one per CloudStack account. - * - * @return the tenant's gateway, or null when the zone is not VRF-segregated or when - * no gateway is registered and falling back to the shared Tier-0 is allowed - * @throws CloudRuntimeException when the zone is VRF-segregated, the tenant has no - * gateway, and fallback is disabled — silently dropping a tenant into the - * shared routing table of a zone declared segregated is the worse failure - */ protected NsxVrfGatewayVO resolveVrfGateway(Long zoneId, long accountId, long domainId) { if (zoneId == null) { return null; @@ -216,30 +208,24 @@ protected NsxVrfGatewayVO resolveVrfGateway(Long zoneId, long accountId, long do if (gateway == null) { gateway = findVrfGatewayForDomainChain(zoneId, domainId); } - if (gateway != null) { logger.debug("Resolved NSX VRF gateway {} for account {} in zone {}", gateway, accountId, zoneId); return gateway; } - if (isVrfFallbackToSharedTier0Allowed(zoneId)) { - logger.warn("Zone {} has {}={} but account {} has no NSX VRF gateway registered; falling back to the " + - "zone-wide tier 0 because {} is enabled. This tenant shares a routing table with the others", - zoneId, NSX_VRF_SCOPE.key(), scope, accountId, NSX_VRF_FALLBACK_TO_SHARED_TIER0.key()); + logger.warn("Account {} has no NSX VRF gateway in zone {}; using the shared Tier-0 because {} is enabled", + accountId, zoneId, NSX_VRF_FALLBACK_TO_SHARED_TIER0.key()); return null; } throw new CloudRuntimeException(String.format( - "Zone %s is segregated by %s=%s but account %s has no NSX VRF gateway registered. Register one with " + - "addNsxVrfGateway and assign it, or enable %s to allow the shared tier 0", + "Zone %s uses %s=%s, but account %s has no NSX VRF gateway. Assign one or enable %s", zoneId, NSX_VRF_SCOPE.key(), scope, accountId, NSX_VRF_FALLBACK_TO_SHARED_TIER0.key())); } - /** Seam over the zone-scoped setting, so the resolution logic is unit-testable. */ protected String getVrfScope(long zoneId) { return NSX_VRF_SCOPE.valueIn(zoneId); } - /** Seam over the zone-scoped setting, so the resolution logic is unit-testable. */ protected boolean isVrfFallbackToSharedTier0Allowed(long zoneId) { return Boolean.TRUE.equals(NSX_VRF_FALLBACK_TO_SHARED_TIER0.valueIn(zoneId)); } @@ -388,11 +374,11 @@ public boolean deleteFirewallRules(Network network, List netRule return result.getResult(); } - public boolean createVpnGateway(Vpc vpc, String localEndpointIp) { + 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.sendNsxCommand(createNsxVpnGatewayCommand, vpc.getZoneId()); - return result.getResult(); + NsxAnswer result = nsxControllerUtils.sendNsxCommandForResult(createNsxVpnGatewayCommand, vpc.getZoneId()); + return new NsxVpnGatewayResult(result.getResult(), result.isEndpointMayBeInUse()); } public boolean deleteVpnGateway(Vpc vpc) { @@ -421,6 +407,13 @@ public boolean deleteVpnConnection(Vpc vpc, String connectionUuid) { 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); @@ -448,8 +441,7 @@ protected void runInContext() { continue; } VpcVO vpc = vpcDao.findById(vpnGateway.getVpcId()); - if (vpc == null || vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId( - Network.Service.Vpn.getName(), Network.Provider.Nsx.getName(), vpc.getVpcOfferingId()) == null) { + if (vpc == null || !isVpnProvidedByNsx(vpc, vpnGateway)) { continue; } polledConnectionIds.add(connection.getId()); @@ -463,6 +455,23 @@ 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 + && vpnGateway != null + && userIpAddressDetailsDao.findDetail(vpnGateway.getAddrId(), NsxElement.NSX_VPN_GATEWAY_IP_DETAIL) != null; + } + protected void pollVpnConnectionStatus(Site2SiteVpnConnectionVO connection, VpcVO vpc) { String status; try { @@ -491,10 +500,16 @@ 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.Connecting) { + 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 { 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 0ef3e2dd63ba..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"); } 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 79f1c0e018e4..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 @@ -23,14 +23,12 @@ import com.cloud.network.vpc.VpcVO; import com.cloud.user.Account; import com.cloud.utils.Pair; -import com.cloud.utils.exception.CloudRuntimeException; 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; import java.util.List; -import java.util.Set; public class NsxHelper { @@ -46,31 +44,15 @@ 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. - * Collisions between connections whose ids are congruent mod 4096 are resolved by - * {@link #findFreeVpnVtiAddressPair(String, Set)} against the tier-1's in-use VTI addresses. + * 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)); } - /** - * Returns the preferred VTI /30 when its local address is not in use on the tier-1 gateway, - * otherwise linear-probes the following slots (wrapping within 169.254.64.0/18) for the first - * free one; throws once all 4096 slots are taken. - */ - public static Pair findFreeVpnVtiAddressPair(String preferredVtiLocalIp, Set inUseVtiLocalIps) { - long startSlot = (NetUtils.ip2Long(preferredVtiLocalIp) - 1 - VPN_VTI_SUBNET_BASE) / 4; - for (long probe = 0; probe < VPN_VTI_SUBNET_SLOTS; probe++) { - long slotBase = VPN_VTI_SUBNET_BASE + ((startSlot + probe) % VPN_VTI_SUBNET_SLOTS) * 4; - String vtiLocalIp = NetUtils.long2Ip(slotBase + 1); - if (!inUseVtiLocalIps.contains(vtiLocalIp)) { - return new Pair<>(vtiLocalIp, NetUtils.long2Ip(slotBase + 2)); - } - } - throw new CloudRuntimeException("No free VTI slots are left in 169.254.64.0/18 for the Site-to-Site VPN connection"); - } - 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/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 7eb784e113f2..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 @@ -37,6 +37,7 @@ 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; @@ -48,11 +49,13 @@ 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; @@ -104,6 +107,7 @@ 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; @@ -147,6 +151,8 @@ public class NsxElementTest { @Mock LoadBalancerVMMapDao lbVmMapDao; @Mock + LoadBalancerDao loadBalancerDao; + @Mock FirewallRuleDetailsDao firewallRuleDetailsDao; @Mock IpAddressManager ipAddressManager; @@ -160,6 +166,8 @@ public class NsxElementTest { UserIpAddressDetailsDao userIpAddressDetailsDao; @Mock FirewallRulesDao firewallRulesDao; + @Mock + PortForwardingRulesDao portForwardingRulesDao; NsxElement nsxElement; ReservationContext reservationContext; @@ -184,6 +192,7 @@ 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; @@ -191,6 +200,11 @@ public void setup() throws NoSuchFieldException, IllegalAccessException { 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); @@ -523,6 +537,8 @@ 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; } @@ -534,11 +550,64 @@ public void testAcquireVpnGatewayIpWithRequestedIp() { IPAddressVO ipAddressVO = mockIpAddressVO(20L, "10.1.13.20"); when(ipAddressVO.getId()).thenReturn(20L); when(ipAddressVO.getVpcId()).thenReturn(9L); - when(firewallRulesDao.countRulesByIpId(20L)).thenReturn(0L); - when(nsxService.createVpnGateway(vpcVO, "10.1.13.20")).thenReturn(true); + 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) @@ -549,12 +618,41 @@ public void testAcquireVpnGatewayIpRejectsSourceNatIp() { 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); @@ -574,7 +672,7 @@ public void testAcquireVpnGatewayIpAutoAcquiresWhenNoIpIsRequested() throws Exce when(allocatedIp.getId()).thenReturn(30L); when(ipAddressManager.allocateIp(any(), anyBoolean(), any(), any(), any(), any(), any())).thenReturn(allocatedIp); IPAddressVO ipAddressVO = mockIpAddressVO(30L, "10.1.13.30"); - when(nsxService.createVpnGateway(vpcVO, "10.1.13.30")).thenReturn(true); + when(nsxService.createVpnGateway(vpcVO, "10.1.13.30")).thenReturn(new NsxVpnGatewayResult(true, true)); IpAddress result = nsxElement.acquireVpnGatewayIp(vpcVO, null); assertEquals(ipAddressVO, result); @@ -585,6 +683,50 @@ public void testAcquireVpnGatewayIpAutoAcquiresWhenNoIpIsRequested() throws Exce } } + @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)); @@ -625,6 +767,97 @@ public void testReleaseVpnGatewayIpKeepsOperatorSpecifiedIp() { 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); @@ -675,6 +908,17 @@ public void testStartSite2SiteVpnRejectsUnsupportedCrypto() throws ResourceUnava 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); @@ -687,16 +931,40 @@ public void testStartSite2SiteVpnIsNoOpWhenVpnIsNotProvidedByNsx() throws Resour 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.deleteVpnConnection(vpcVO, "conn-uuid")).thenReturn(true); + 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); 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 a59ef8bdfd59..a56d22f993cf 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxServiceImplTest.java @@ -16,58 +16,50 @@ // under the License. package org.apache.cloudstack.service; -import java.util.List; - -import com.cloud.alert.AlertManager; +import com.cloud.network.IpAddress; import com.cloud.domain.DomainVO; import com.cloud.domain.dao.DomainDao; +import com.cloud.network.dao.NetworkVO; import com.cloud.network.dao.NsxVrfGatewayDao; import com.cloud.network.element.NsxVrfGatewayVO; -import com.cloud.network.IpAddress; 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.vpc.Vpc; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; -import com.cloud.network.vpc.dao.VpcOfferingServiceMapDao; +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.CreateNsxVpnConnectionCommand; 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; 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.utils.NsxControllerUtils; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; 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.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; 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; @@ -78,14 +70,6 @@ public class NsxServiceImplTest { @Mock private VpcDao vpcDao; @Mock - private VpcOfferingServiceMapDao vpcOfferingServiceMapDao; - @Mock - private Site2SiteVpnConnectionDao site2SiteVpnConnectionDao; - @Mock - private Site2SiteVpnGatewayDao site2SiteVpnGatewayDao; - @Mock - private AlertManager alertManager; - @Mock private NsxVrfGatewayDao nsxVrfGatewayDao; @Mock private DomainDao domainDao; @@ -103,10 +87,6 @@ public void setup() { nsxService = Mockito.spy(new NsxServiceImpl()); nsxService.nsxControllerUtils = nsxControllerUtils; nsxService.vpcDao = vpcDao; - nsxService.vpcOfferingServiceMapDao = vpcOfferingServiceMapDao; - nsxService.site2SiteVpnConnectionDao = site2SiteVpnConnectionDao; - nsxService.site2SiteVpnGatewayDao = site2SiteVpnGatewayDao; - nsxService.alertManager = alertManager; nsxService.nsxVrfGatewayDao = nsxVrfGatewayDao; nsxService.domainDao = domainDao; } @@ -137,7 +117,6 @@ public void testResolveVrfGatewayPrefersTheAccountAssignment() { Mockito.doReturn("ACCOUNT").when(nsxService).getVrfScope(zoneId); NsxVrfGatewayVO expected = vrfGateway("CS-VRF-001", "v5-EdgeCluster-HOSTED"); when(nsxVrfGatewayDao.findByAccount(zoneId, accountId)).thenReturn(expected); - assertEquals(expected, nsxService.resolveVrfGateway(zoneId, accountId, domainId)); verify(domainDao, never()).findById(anyLong()); } @@ -151,7 +130,6 @@ public void testResolveVrfGatewayFallsBackToTheDomainWhenTheAccountHasNone() { when(domain.getId()).thenReturn(domainId); when(domainDao.findById(domainId)).thenReturn(domain); when(nsxVrfGatewayDao.findByDomain(zoneId, domainId)).thenReturn(expected); - assertEquals(expected, nsxService.resolveVrfGateway(zoneId, accountId, domainId)); } @@ -160,18 +138,15 @@ public void testResolveVrfGatewayWalksUpTheDomainChain() { Mockito.doReturn("DOMAIN").when(nsxService).getVrfScope(zoneId); long parentDomainId = 7L; NsxVrfGatewayVO expected = vrfGateway("CS-VRF-003", "v5-EdgeCluster-HOSTED"); - DomainVO child = mock(DomainVO.class); when(child.getId()).thenReturn(domainId); when(child.getParent()).thenReturn(parentDomainId); DomainVO parent = mock(DomainVO.class); when(parent.getId()).thenReturn(parentDomainId); - when(domainDao.findById(domainId)).thenReturn(child); when(domainDao.findById(parentDomainId)).thenReturn(parent); when(nsxVrfGatewayDao.findByDomain(zoneId, domainId)).thenReturn(null); when(nsxVrfGatewayDao.findByDomain(zoneId, parentDomainId)).thenReturn(expected); - assertEquals(expected, nsxService.resolveVrfGateway(zoneId, accountId, domainId)); } @@ -181,7 +156,6 @@ public void testResolveVrfGatewayFallsBackToSharedTier0WhenAllowed() { Mockito.doReturn(true).when(nsxService).isVrfFallbackToSharedTier0Allowed(zoneId); when(nsxVrfGatewayDao.findByAccount(zoneId, accountId)).thenReturn(null); when(domainDao.findById(domainId)).thenReturn(null); - assertNull(nsxService.resolveVrfGateway(zoneId, accountId, domainId)); } @@ -191,7 +165,6 @@ public void testResolveVrfGatewayFailsWhenSegregatedAndUnassigned() { Mockito.doReturn(false).when(nsxService).isVrfFallbackToSharedTier0Allowed(zoneId); when(nsxVrfGatewayDao.findByAccount(zoneId, accountId)).thenReturn(null); when(domainDao.findById(domainId)).thenReturn(null); - nsxService.resolveVrfGateway(zoneId, accountId, domainId); } @@ -202,9 +175,7 @@ public void testApplyVrfGatewaySetsTier0AndEdgeClusterOnTheCommand() { .thenReturn(vrfGateway("CS-VRF-001", "v5-EdgeCluster-HOSTED")); CreateNsxTier1GatewayCommand cmd = new CreateNsxTier1GatewayCommand(domainId, accountId, zoneId, 1L, "VPC01", true, true); - nsxService.applyVrfGateway(cmd, zoneId, accountId, domainId); - assertEquals("CS-VRF-001", cmd.getTier0Gateway()); assertEquals("v5-EdgeCluster-HOSTED", cmd.getEdgeCluster()); } @@ -214,11 +185,7 @@ public void testApplyVrfGatewayLeavesCommandUntouchedWhenScopeIsNone() { Mockito.doReturn("NONE").when(nsxService).getVrfScope(zoneId); CreateNsxTier1GatewayCommand cmd = new CreateNsxTier1GatewayCommand(domainId, accountId, zoneId, 1L, "VPC01", true, true); - nsxService.applyVrfGateway(cmd, zoneId, accountId, domainId); - - // Null on both means the resource keeps using the zone-wide values, which is what - // makes this change a no-op for every deployment that does not opt in. assertNull(cmd.getTier0Gateway()); assertNull(cmd.getEdgeCluster()); } @@ -232,6 +199,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); @@ -311,232 +297,53 @@ public void testDeleteStaticNatRule() { Mockito.verify(nsxControllerUtils).sendNsxCommand(any(DeleteNsxNatRuleCommand.class), eq(zoneId)); } - private VpcVO mockVpc() { - VpcVO vpc = mock(VpcVO.class); - when(vpc.getDomainId()).thenReturn(domainId); - when(vpc.getAccountId()).thenReturn(accountId); - when(vpc.getZoneId()).thenReturn(zoneId); - when(vpc.getId()).thenReturn(9L); - when(vpc.getName()).thenReturn("VPC01"); - return vpc; - } - - @Test - public void testCreateVpnGateway() { - VpcVO vpc = mockVpc(); - NsxAnswer answer = mock(NsxAnswer.class); - when(answer.getResult()).thenReturn(true); - when(nsxControllerUtils.sendNsxCommand(any(CreateNsxVpnGatewayCommand.class), eq(zoneId))).thenReturn(answer); - - assertTrue(nsxService.createVpnGateway(vpc, "10.1.13.30")); - Mockito.verify(nsxControllerUtils).sendNsxCommand(any(CreateNsxVpnGatewayCommand.class), eq(zoneId)); - } - - @Test - public void testDeleteVpnGateway() { - VpcVO vpc = mockVpc(); - NsxAnswer answer = mock(NsxAnswer.class); - when(answer.getResult()).thenReturn(true); - when(nsxControllerUtils.sendNsxCommand(any(DeleteNsxVpnGatewayCommand.class), eq(zoneId))).thenReturn(answer); - - assertTrue(nsxService.deleteVpnGateway(vpc)); - Mockito.verify(nsxControllerUtils).sendNsxCommand(any(DeleteNsxVpnGatewayCommand.class), eq(zoneId)); - } - - @Test - public void testCreateVpnConnection() { - VpcVO vpc = mockVpc(); - when(vpc.getCidr()).thenReturn("10.10.0.0/16"); - NsxAnswer answer = mock(NsxAnswer.class); - when(answer.getResult()).thenReturn(true); - when(nsxControllerUtils.sendNsxCommand(any(CreateNsxVpnConnectionCommand.class), eq(zoneId))).thenReturn(answer); - - assertTrue(nsxService.createVpnConnection(vpc, "conn-uuid", "203.0.113.10", "presharedkey", - "aes256-sha256;modp2048", "aes128-sha1", 86400L, 3600L, true, "ikev2", false, - List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, "10.1.13.30")); - Mockito.verify(nsxControllerUtils).sendNsxCommand(any(CreateNsxVpnConnectionCommand.class), eq(zoneId)); - } - - @Test - public void testDeleteVpnConnection() { - VpcVO vpc = mockVpc(); - NsxAnswer answer = mock(NsxAnswer.class); - when(answer.getResult()).thenReturn(true); - when(nsxControllerUtils.sendNsxCommand(any(DeleteNsxVpnConnectionCommand.class), eq(zoneId))).thenReturn(answer); - - assertTrue(nsxService.deleteVpnConnection(vpc, "conn-uuid")); - Mockito.verify(nsxControllerUtils).sendNsxCommand(any(DeleteNsxVpnConnectionCommand.class), eq(zoneId)); - } - - @Test - public void testGetVpnConnectionStatus() { - VpcVO vpc = mockVpc(); - NsxAnswer answer = mock(NsxAnswer.class); - when(answer.getDetails()).thenReturn("UP"); - when(nsxControllerUtils.sendNsxCommand(any(GetNsxVpnSessionStatusCommand.class), eq(zoneId))).thenReturn(answer); - - assertEquals("UP", nsxService.getVpnConnectionStatus(vpc, "conn-uuid")); - } - @Test - public void testCreateVpnConnectionCommandPayload() { - VpcVO vpc = mockVpc(); - when(vpc.getCidr()).thenReturn("10.10.0.0/16"); - NsxAnswer answer = mock(NsxAnswer.class); - when(answer.getResult()).thenReturn(true); - when(nsxControllerUtils.sendNsxCommand(any(CreateNsxVpnConnectionCommand.class), eq(zoneId))).thenReturn(answer); - - assertTrue(nsxService.createVpnConnection(vpc, "conn-uuid", "203.0.113.10", "presharedkey", - "aes256-sha256;modp2048", "aes128-sha1", 86400L, 3600L, true, "ikev2", true, - List.of("192.168.100.0/24"), "169.254.64.21", "169.254.64.22", 30, "10.1.13.30")); - - ArgumentCaptor captor = ArgumentCaptor.forClass(CreateNsxVpnConnectionCommand.class); - verify(nsxControllerUtils).sendNsxCommand(captor.capture(), eq(zoneId)); - CreateNsxVpnConnectionCommand command = captor.getValue(); - assertEquals("conn-uuid", command.getConnectionUuid()); - assertEquals("203.0.113.10", command.getPeerAddress()); - assertEquals("presharedkey", command.getPsk()); - assertEquals("169.254.64.21", command.getVtiLocalIp()); - assertEquals("169.254.64.22", command.getVtiPeerIp()); - assertEquals(30, command.getVtiPrefixLength()); - assertEquals("10.10.0.0/16", command.getVpcCidr()); - assertEquals(List.of("192.168.100.0/24"), command.getPeerCidrs()); - assertEquals("10.1.13.30", command.getLocalEndpointIp()); - assertTrue(command.isPassive()); - } - - private Site2SiteVpnConnectionVO vpnConnection(Site2SiteVpnConnection.State state) { - Site2SiteVpnConnectionVO connection = new Site2SiteVpnConnectionVO(accountId, domainId, 4L, 5L, false); - connection.setState(state); - return connection; - } - - private NsxAnswer mockVpnStatusAnswer(String status) { - NsxAnswer answer = mock(NsxAnswer.class); - when(answer.getDetails()).thenReturn(status); - when(nsxControllerUtils.sendNsxCommand(any(GetNsxVpnSessionStatusCommand.class), eq(zoneId))).thenReturn(answer); - return answer; - } - - @Test - public void testPollVpnConnectionStatusTransitionsToConnectedOnUp() { - VpcVO vpc = mockVpc(); - Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Disconnected); - mockVpnStatusAnswer("UP"); - when(site2SiteVpnConnectionDao.acquireInLockTable(connection.getId())).thenReturn(connection); - when(site2SiteVpnConnectionDao.findById(connection.getId())).thenReturn(connection); - - nsxService.pollVpnConnectionStatus(connection, vpc); - - assertEquals(Site2SiteVpnConnection.State.Connected, connection.getState()); - verify(site2SiteVpnConnectionDao).persist(connection); - verify(alertManager).sendAlert(any(), eq(zoneId), isNull(), anyString(), anyString()); - } - - @Test - public void testPollVpnConnectionStatusTransitionsToDisconnectedOnDown() { - VpcVO vpc = mockVpc(); - Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Connected); - mockVpnStatusAnswer("DOWN"); - when(site2SiteVpnConnectionDao.acquireInLockTable(connection.getId())).thenReturn(connection); - when(site2SiteVpnConnectionDao.findById(connection.getId())).thenReturn(connection); - - nsxService.pollVpnConnectionStatus(connection, vpc); - - assertEquals(Site2SiteVpnConnection.State.Disconnected, connection.getState()); - verify(site2SiteVpnConnectionDao).persist(connection); - } - - @Test - public void testPollVpnConnectionStatusFailuresNeverTransitionState() { - VpcVO vpc = mockVpc(); - Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Connected); - when(nsxControllerUtils.sendNsxCommand(any(GetNsxVpnSessionStatusCommand.class), eq(zoneId))) - .thenThrow(new CloudRuntimeException("NSX unreachable")); - - for (int i = 0; i < NsxServiceImpl.VPN_STATUS_POLL_FAILURE_THRESHOLD; i++) { - nsxService.pollVpnConnectionStatus(connection, vpc); - } - - assertEquals(Site2SiteVpnConnection.State.Connected, connection.getState()); - verify(site2SiteVpnConnectionDao, never()).persist(any()); - verify(alertManager, times(1)).sendAlert(any(), eq(zoneId), isNull(), anyString(), anyString()); - - // the failure counter was reset at the threshold: a single further failure does not alert again - nsxService.pollVpnConnectionStatus(connection, vpc); - verify(alertManager, times(1)).sendAlert(any(), eq(zoneId), isNull(), anyString(), anyString()); - } - - @Test - public void testPollVpnConnectionStatusFailureCounterResetsOnSuccess() { - VpcVO vpc = mockVpc(); - Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Connected); - NsxAnswer answer = mock(NsxAnswer.class); - when(answer.getDetails()).thenReturn("UP"); - when(nsxControllerUtils.sendNsxCommand(any(GetNsxVpnSessionStatusCommand.class), eq(zoneId))) - .thenThrow(new CloudRuntimeException("NSX unreachable")) - .thenThrow(new CloudRuntimeException("NSX unreachable")) - .thenReturn(answer) - .thenThrow(new CloudRuntimeException("NSX unreachable")) - .thenThrow(new CloudRuntimeException("NSX unreachable")); - - for (int i = 0; i < 5; i++) { - nsxService.pollVpnConnectionStatus(connection, vpc); - } - - // never three consecutive failures, so no alert and no transition - verify(alertManager, never()).sendAlert(any(), anyLong(), isNull(), anyString(), anyString()); - assertEquals(Site2SiteVpnConnection.State.Connected, connection.getState()); - } - - @Test - public void testPollVpnConnectionStatusTransitionsToErrorWhenSessionVanished() { - VpcVO vpc = mockVpc(); - Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Connected); - mockVpnStatusAnswer("NOT_FOUND"); - when(site2SiteVpnConnectionDao.acquireInLockTable(connection.getId())).thenReturn(connection); - when(site2SiteVpnConnectionDao.findById(connection.getId())).thenReturn(connection); - - nsxService.pollVpnConnectionStatus(connection, vpc); - - assertEquals(Site2SiteVpnConnection.State.Error, connection.getState()); - verify(site2SiteVpnConnectionDao).persist(connection); - } - - @Test - public void testPollVpnConnectionStatusKeepsConnectingStateWhenSessionNotFound() { - VpcVO vpc = mockVpc(); - Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Connecting); - mockVpnStatusAnswer("NOT_FOUND"); - - nsxService.pollVpnConnectionStatus(connection, vpc); + public void testPollVpnConnectionStatusTransitionsUp() { + Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); + VpcVO vpc = mock(VpcVO.class); + AtomicReference transitionedState = new AtomicReference<>(); - assertEquals(Site2SiteVpnConnection.State.Connecting, connection.getState()); - verify(site2SiteVpnConnectionDao, never()).persist(any()); - } + NsxServiceImpl service = new NsxServiceImpl() { + @Override + public String getVpnConnectionStatus(Vpc vpc, String connectionUuid) { + return "UP"; + } - @Test - public void testTransitionVpnConnectionStateSkipsWhenLockCannotBeAcquired() { - VpcVO vpc = mock(VpcVO.class); - Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Disconnected); - when(site2SiteVpnConnectionDao.acquireInLockTable(connection.getId())).thenReturn(null); + @Override + protected void transitionVpnConnectionState(Site2SiteVpnConnectionVO connection, VpcVO vpc, + Site2SiteVpnConnection.State newState) { + transitionedState.set(newState); + } + }; - nsxService.transitionVpnConnectionState(connection, vpc, Site2SiteVpnConnection.State.Connected); + service.pollVpnConnectionStatus(connection, vpc); - verify(site2SiteVpnConnectionDao, never()).persist(any()); + assertEquals(Site2SiteVpnConnection.State.Connected, transitionedState.get()); } @Test - public void testTransitionVpnConnectionStateRechecksStateUnderLock() { + public void testPollVpnConnectionStatusDoesNotTransitionOnQueryFailure() { + Site2SiteVpnConnectionVO connection = mock(Site2SiteVpnConnectionVO.class); VpcVO vpc = mock(VpcVO.class); - Site2SiteVpnConnectionVO connection = vpnConnection(Site2SiteVpnConnection.State.Disconnected); - Site2SiteVpnConnectionVO lockedConnection = vpnConnection(Site2SiteVpnConnection.State.Pending); - when(site2SiteVpnConnectionDao.acquireInLockTable(connection.getId())).thenReturn(connection); - when(site2SiteVpnConnectionDao.findById(connection.getId())).thenReturn(lockedConnection); - - nsxService.transitionVpnConnectionState(connection, vpc, Site2SiteVpnConnection.State.Connected); - - verify(site2SiteVpnConnectionDao, never()).persist(any()); - verify(alertManager, never()).sendAlert(any(), anyLong(), isNull(), anyString(), anyString()); + 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 index 10ba2f5f8dfc..8d32e2499110 100644 --- 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 @@ -16,13 +16,9 @@ // under the License. package org.apache.cloudstack.utils; -import java.util.HashSet; -import java.util.Set; - import org.junit.Test; import com.cloud.utils.Pair; -import com.cloud.utils.exception.CloudRuntimeException; import static org.junit.Assert.assertEquals; @@ -54,35 +50,4 @@ public void testVpnVtiAddressPairWrapsAfterSubnetIsExhausted() { assertEquals(NsxHelper.getVpnVtiAddressPair(1L), NsxHelper.getVpnVtiAddressPair(4097L)); } - @Test - public void testFindFreeVpnVtiAddressPairReturnsPreferredSlotWhenFree() { - Pair vtiAddresses = NsxHelper.findFreeVpnVtiAddressPair("169.254.64.21", Set.of()); - assertEquals("169.254.64.21", vtiAddresses.first()); - assertEquals("169.254.64.22", vtiAddresses.second()); - } - - @Test - public void testFindFreeVpnVtiAddressPairProbesPastInUseSlots() { - Pair vtiAddresses = NsxHelper.findFreeVpnVtiAddressPair("169.254.64.21", - Set.of("169.254.64.21", "169.254.64.25")); - assertEquals("169.254.64.29", vtiAddresses.first()); - assertEquals("169.254.64.30", vtiAddresses.second()); - } - - @Test - public void testFindFreeVpnVtiAddressPairWrapsAroundTheSubnet() { - Pair vtiAddresses = NsxHelper.findFreeVpnVtiAddressPair("169.254.127.253", - Set.of("169.254.127.253")); - assertEquals("169.254.64.1", vtiAddresses.first()); - assertEquals("169.254.64.2", vtiAddresses.second()); - } - - @Test(expected = CloudRuntimeException.class) - public void testFindFreeVpnVtiAddressPairThrowsWhenAllSlotsAreInUse() { - Set inUseVtiLocalIps = new HashSet<>(); - for (long slot = 0; slot < 4096; slot++) { - inUseVtiLocalIps.add(NsxHelper.getVpnVtiAddressPair(slot).first()); - } - NsxHelper.findFreeVpnVtiAddressPair("169.254.64.1", inUseVtiLocalIps); - } } 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 dd2a87d3edf9..4e09f9852e85 100644 --- a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java @@ -217,12 +217,20 @@ public Site2SiteVpnGateway createVpnGateway(CreateVpnGatewayCmd cmd) { throw new InvalidParameterValueException(String.format("The VPN gateway of VPC %s already exists!", vpc)); } - if (getVpnServiceProvidersForVpc(vpcId).isEmpty()) { - 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)); + 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)); } - IPAddressVO requestedIp = _ipAddressDao.findById(cmd.getIpAddressId()); - IPAddressVO ipAddress = getIpAddressIdForVpn(vpcId, vpc.getVpcOfferingId(), requestedIp); + 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) { @@ -232,14 +240,11 @@ public Site2SiteVpnGateway createVpnGateway(CreateVpnGatewayCmd cmd) { try { _vpnGatewayDao.persist(gw); } catch (RuntimeException e) { - // give back the IP and provider-side VPN resources a provider may have prepared for this gateway - for (Site2SiteVpnServiceProvider provider : getVpnServiceProvidersForVpc(vpcId)) { - try { - provider.releaseVpnGatewayIp(gw); - } catch (Exception releaseException) { - logger.warn("Failed to release the VPN gateway IP of VPC {} after the VPN gateway could not be persisted: {}", - vpc, releaseException.getMessage()); - } + 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; } @@ -250,49 +255,99 @@ private List getVpnServiceProvidersForVpc(long vpcI List providers = new ArrayList<>(); for (Site2SiteVpnServiceProvider provider : _s2sProviders) { if (provider instanceof NetworkElement - && vpcManager.isProviderSupportServiceInVpc(vpcId, Network.Service.Vpn, ((NetworkElement) provider).getProvider())) { + && vpcManager.isProviderSupportServiceInVpc(vpcId, Network.Service.Vpn, + ((NetworkElement) provider).getProvider())) { providers.add(provider); } } return providers; } - private IPAddressVO getIpAddressIdForVpn(Long vpcId, Long vpcOferingId, IPAddressVO requestedIp) { - // 1) A Site-to-Site VPN provider (e.g. an external provider terminating VPN on its own - // gateway) may supply a dedicated gateway IP instead of the VPC source NAT IP - Vpc vpcForProvider = _vpcDao.findById(vpcId); - for (Site2SiteVpnServiceProvider provider : getVpnServiceProvidersForVpc(vpcId)) { - IpAddress providerIp = provider.acquireVpnGatewayIp(vpcForProvider, requestedIp); - if (providerIp != null) { - logger.debug("Using VPN gateway IP {} supplied by provider {} for VPC {}", providerIp.getAddress(), provider.getName(), vpcForProvider); - return _ipAddressDao.findById(providerIp.getId()); + 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(), vpcOferingId); - VpcOfferingServiceMapVO mapForVpn = vpcOfferingServiceMapDao.findByServiceProviderAndOfferingId(Network.Service.Vpn.getName(), Network.Provider.VPCVirtualRouter.getName(), vpcOferingId); + 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"); } return ipAddressForVpcVR; } else { - // Use the VPC source NAT IP, excluding system-vm helper IPs: on NSX-backed VPCs the - // VR's public IP is also flagged source_nat (with forsystemvms=1) for redeploy idempotency - List ips = _ipAddressDao.listByAssociatedVpc(vpcId, true).stream() + // The VR helper address can also be marked source NAT for redeploy idempotency. + // It is not the customer-facing source NAT address used by the legacy VPN provider. + List ips = _ipAddressDao.listByAssociatedVpc(vpc.getId(), true).stream() .filter(ip -> !ip.isForSystemVms()) .collect(Collectors.toList()); if (ips.isEmpty()) { - throw new CloudRuntimeException(String.format("No source NAT IP found for VPC %s; Site-to-Site VPN requires the VPC source NAT IP", vpcId)); + throw new CloudRuntimeException("No source NAT IP found for VPC " + vpc.getId()); } if (ips.size() > 1) { String addresses = ips.stream().map(ip -> ip.getAddress().addr()).collect(Collectors.joining(", ")); - throw new CloudRuntimeException(String.format("Multiple source NAT IPs (%s) found for VPC %s while exactly one was expected", addresses, vpcId)); + throw new CloudRuntimeException(String.format( + "Multiple source NAT IPs (%s) found for VPC %s while exactly one was expected", + addresses, 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())); @@ -301,6 +356,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); @@ -527,6 +592,7 @@ public Site2SiteVpnConnection createVpnConnection(CreateVpnConnectionCmd cmd) { validateVpnConnectionOfTheRightAccount(customerGateway, vpnGateway); validateVpnConnectionDoesntExist(customerGateway, vpnGateway); validatePrerequisiteVpnGateway(vpnGateway); + validateCustomerGatewayForVpnGateway(customerGateway, vpnGateway); String[] cidrList = customerGateway.getGuestCidrList().split(","); @@ -608,6 +674,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) @@ -626,16 +702,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 : getVpnServiceProvidersForVpc(vpnGateway.getVpcId())) { - 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()) { @@ -649,6 +731,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()); } @@ -692,9 +783,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)); } - for (Site2SiteVpnServiceProvider provider : getVpnServiceProvidersForVpc(gw.getVpcId())) { - provider.releaseVpnGatewayIp(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()); } @@ -788,6 +881,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); @@ -853,9 +963,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); @@ -874,12 +982,17 @@ 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!"); } @@ -887,10 +1000,16 @@ private void stopVpnConnection(Long id) throws ResourceUnavailableException { _vpnConnectionDao.persist(conn); Site2SiteVpnGateway vpnGateway = _vpnGatewayDao.findById(conn.getVpnGatewayId()); - boolean result = true; - for (Site2SiteVpnServiceProvider element : getVpnServiceProvidersForVpc(vpnGateway.getVpcId())) { - result = result & element.stopSite2SiteVpn(conn); + 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); @@ -1120,10 +1239,13 @@ public List getConnectionsForRouter(DomainRouterVO rou if (router.getVpcId() == null) { return conns; } - // Only the connections the VPC VR actually terminates: when VPN is provided by an external - // provider the router knows nothing about them, and reporting on them would overwrite the - // state that provider reports - if (!vpcManager.isProviderSupportServiceInVpc(vpcId, Network.Service.Vpn, Network.Provider.VPCVirtualRouter)) { + 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)); 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 6b3db7e07ece..4e77f66d469d 100644 --- a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java @@ -20,10 +20,10 @@ import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.ResourceUnavailableException; -import com.cloud.network.Network; 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,8 +32,8 @@ 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.element.NetworkElement; import com.cloud.network.vpc.VpcManager; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; @@ -56,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; @@ -78,7 +79,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; +import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyLong; @@ -119,7 +120,6 @@ public class Site2SiteVpnManagerImplTest { private AccountVO account; private UserVO user; private VpcVO vpc; - private Site2SiteVpnServiceProvider s2sProvider; private IPAddressVO ipAddress; private Site2SiteVpnGatewayVO vpnGateway; private Site2SiteCustomerGatewayVO customerGateway; @@ -151,12 +151,6 @@ public void setUp() throws Exception { when(ipAddress.getId()).thenReturn(IP_ADDRESS_ID); when(ipAddress.getVpcId()).thenReturn(VPC_ID); - s2sProvider = mock(Site2SiteVpnServiceProvider.class, - Mockito.withSettings().extraInterfaces(NetworkElement.class)); - when(((NetworkElement) s2sProvider).getProvider()).thenReturn(Network.Provider.VPCVirtualRouter); - when(_s2sProviders.iterator()).thenAnswer(invocation -> List.of(s2sProvider).iterator()); - when(_vpcMgr.isProviderSupportServiceInVpc(anyLong(), any(), any())).thenReturn(true); - vpnGateway = mock(Site2SiteVpnGatewayVO.class); when(vpnGateway.getId()).thenReturn(VPN_GATEWAY_ID); when(vpnGateway.getVpcId()).thenReturn(VPC_ID); @@ -177,6 +171,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 @@ -226,9 +241,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); @@ -238,73 +255,81 @@ public void testCreateVpnGatewaySuccess() { verify(_vpnGatewayDao).persist(any(Site2SiteVpnGatewayVO.class)); } - @Test(expected = InvalidParameterValueException.class) - public void testCreateVpnGatewayInvalidVpc() { + @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); - when(_vpcDao.findById(VPC_ID)).thenReturn(null); + Site2SiteVpnGateway result = site2SiteVpnManager.createVpnGateway(cmd); - site2SiteVpnManager.createVpnGateway(cmd); + assertNotNull(result); + verify(provider, never()).releaseVpnGatewayIp(any(Site2SiteVpnGateway.class)); } - @Test(expected = InvalidParameterValueException.class) - public void testCreateVpnGatewayAlreadyExists() { + @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(vpnGateway); + 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); - site2SiteVpnManager.createVpnGateway(cmd); + assertThrows(CloudRuntimeException.class, () -> site2SiteVpnManager.createVpnGateway(cmd)); + + verify(provider).releaseVpnGatewayIp(any(Site2SiteVpnGateway.class)); + verify(_vpnGatewayDao, never()).persist(any(Site2SiteVpnGatewayVO.class)); } - @Test(expected = CloudRuntimeException.class) - public void testCreateVpnGatewayNoSourceNatIp() { + @Test(expected = InvalidParameterValueException.class) + public void testCreateVpnGatewayInvalidVpc() { CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); - when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); - when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); - when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(new ArrayList<>()); + when(_vpcDao.findById(VPC_ID)).thenReturn(null); site2SiteVpnManager.createVpnGateway(cmd); } - @Test - public void testCreateVpnGatewayReleasesProviderIpWhenPersistFails() { + @Test(expected = InvalidParameterValueException.class) + public void testCreateVpnGatewayAlreadyExists() { CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); - when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); - when(s2sProvider.acquireVpnGatewayIp(any(), any())).thenReturn(ipAddress); - when(_ipAddressDao.findById(IP_ADDRESS_ID)).thenReturn(ipAddress); - CloudRuntimeException persistFailure = new CloudRuntimeException("persist failed"); - when(_vpnGatewayDao.persist(any(Site2SiteVpnGatewayVO.class))).thenThrow(persistFailure); + when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(vpnGateway); - try { - site2SiteVpnManager.createVpnGateway(cmd); - fail("The persist failure should have been rethrown"); - } catch (CloudRuntimeException e) { - assertEquals(persistFailure, e); - } - verify(s2sProvider).releaseVpnGatewayIp(any(Site2SiteVpnGateway.class)); + site2SiteVpnManager.createVpnGateway(cmd); } - @Test(expected = InvalidParameterValueException.class) - public void testCreateVpnGatewayVpnServiceNotSupported() { + @Test(expected = CloudRuntimeException.class) + public void testCreateVpnGatewayNoSourceNatIp() { CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); - when(_vpcMgr.isProviderSupportServiceInVpc(anyLong(), any(), any())).thenReturn(false); + mockVpcVirtualRouterProvider(); + when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(new ArrayList<>()); site2SiteVpnManager.createVpnGateway(cmd); } @@ -315,12 +340,11 @@ public void testCreateVpnGatewaySkipsSystemVmSourceNatIp() { when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); when(cmd.isDisplay()).thenReturn(true); - IPAddressVO systemVmIp = mock(IPAddressVO.class); when(systemVmIp.isForSystemVms()).thenReturn(true); - when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + mockVpcVirtualRouterProvider(); when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(List.of(systemVmIp, ipAddress)); when(_vpnGatewayDao.persist(any(Site2SiteVpnGatewayVO.class))).thenReturn(vpnGateway); @@ -332,50 +356,17 @@ public void testCreateVpnGatewaySkipsSystemVmSourceNatIp() { assertEquals(IP_ADDRESS_ID.longValue(), gatewayCaptor.getValue().getAddrId()); } - @Test - public void testCreateVpnGatewayUsesProviderAcquiredIp() { - CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); - when(cmd.getVpcId()).thenReturn(VPC_ID); - when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); - when(cmd.isDisplay()).thenReturn(true); - - when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); - when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); - when(s2sProvider.acquireVpnGatewayIp(any(), any())).thenReturn(ipAddress); - when(_ipAddressDao.findById(IP_ADDRESS_ID)).thenReturn(ipAddress); - when(_vpnGatewayDao.persist(any(Site2SiteVpnGatewayVO.class))).thenReturn(vpnGateway); - - Site2SiteVpnGateway result = site2SiteVpnManager.createVpnGateway(cmd); - - assertNotNull(result); - ArgumentCaptor gatewayCaptor = ArgumentCaptor.forClass(Site2SiteVpnGatewayVO.class); - verify(_vpnGatewayDao).persist(gatewayCaptor.capture()); - assertEquals(IP_ADDRESS_ID.longValue(), gatewayCaptor.getValue().getAddrId()); - verify(_ipAddressDao, never()).listByAssociatedVpc(anyLong(), anyBoolean()); - } - - @Test - public void testDoDeleteVpnGatewayReleasesProviderIp() { - when(_vpnConnectionDao.listByVpnGatewayId(VPN_GATEWAY_ID)).thenReturn(new ArrayList<>()); - - site2SiteVpnManager.doDeleteVpnGateway(vpnGateway); - - verify(s2sProvider).releaseVpnGatewayIp(vpnGateway); - verify(_vpnGatewayDao).remove(VPN_GATEWAY_ID); - } - @Test(expected = CloudRuntimeException.class) - public void testCreateVpnGatewayMultipleSourceNatIps() { + public void testCreateVpnGatewayRejectsMultipleCustomerSourceNatIps() { CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); - IPAddressVO secondIp = mock(IPAddressVO.class); when(ipAddress.getAddress()).thenReturn(new Ip("203.0.113.34")); when(secondIp.getAddress()).thenReturn(new Ip("203.0.113.35")); - when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); + mockVpcVirtualRouterProvider(); when(_ipAddressDao.listByAssociatedVpc(VPC_ID, true)).thenReturn(List.of(ipAddress, secondIp)); site2SiteVpnManager.createVpnGateway(cmd); @@ -596,6 +587,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); @@ -616,6 +608,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++) { @@ -626,6 +619,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); @@ -658,6 +704,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); @@ -683,13 +730,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 @@ -697,9 +748,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); @@ -709,6 +759,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); @@ -726,10 +813,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); @@ -752,6 +838,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); @@ -773,6 +860,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); @@ -814,9 +903,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 955ddbadcd85fe0d00abdc6c9c6a721fdc10d986 Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:00:31 +0100 Subject: [PATCH 03/30] test: exercise legacy VPN gateway IP fallback --- .../com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java index 4e77f66d469d..b23ed42d29b7 100644 --- a/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpn/Site2SiteVpnManagerImplTest.java @@ -325,6 +325,7 @@ public void testCreateVpnGatewayNoSourceNatIp() { CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(cmd.getIpAddressId()).thenReturn(null); when(_vpcDao.findById(VPC_ID)).thenReturn(vpc); when(_vpnGatewayDao.findByVpcId(VPC_ID)).thenReturn(null); @@ -339,6 +340,7 @@ public void testCreateVpnGatewaySkipsSystemVmSourceNatIp() { CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(cmd.getIpAddressId()).thenReturn(null); when(cmd.isDisplay()).thenReturn(true); IPAddressVO systemVmIp = mock(IPAddressVO.class); when(systemVmIp.isForSystemVms()).thenReturn(true); @@ -361,6 +363,7 @@ public void testCreateVpnGatewayRejectsMultipleCustomerSourceNatIps() { CreateVpnGatewayCmd cmd = mock(CreateVpnGatewayCmd.class); when(cmd.getVpcId()).thenReturn(VPC_ID); when(cmd.getEntityOwnerId()).thenReturn(ACCOUNT_ID); + when(cmd.getIpAddressId()).thenReturn(null); IPAddressVO secondIp = mock(IPAddressVO.class); when(ipAddress.getAddress()).thenReturn(new Ip("203.0.113.34")); when(secondIp.getAddress()).thenReturn(new Ip("203.0.113.35")); From 92ef15a71c0dcc2b93d5bedb9d9d8836e76bc1ef 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 04/30] 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 | 83 ++++ .../vpn/RemoteAccessVpnManagerImpl.java | 54 ++- .../network/vpn/Site2SiteVpnManagerImpl.java | 210 +++++----- .../vpn/RemoteAccessVpnManagerImplTest.java | 137 ++++++ .../vpn/Site2SiteVpnManagerImplTest.java | 227 +++++++++- 16 files changed, 1415 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 530c4181473c..e2cc3cc03ad5 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 @@ -593,7 +593,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 @@ -605,23 +605,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 ba5b4988b5d4..e2ebb70a2c50 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxServiceImpl.java @@ -74,10 +74,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; @@ -122,10 +120,6 @@ public class NsxServiceImpl extends ManagerBase implements NsxService, Configura @Inject VpcDao vpcDao; @Inject - VpcOfferingServiceMapDao vpcOfferingServiceMapDao; - @Inject - VpcManager vpcManager; - @Inject Site2SiteVpnConnectionDao site2SiteVpnConnectionDao; @Inject Site2SiteVpnGatewayDao site2SiteVpnGatewayDao; @@ -422,9 +416,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 @@ -455,24 +449,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()); @@ -487,7 +472,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()); @@ -500,12 +485,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; @@ -516,11 +501,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()); @@ -531,7 +518,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 a56d22f993cf..c009b0b088cb 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 @@ -24,6 +24,9 @@ import com.cloud.network.element.NsxVrfGatewayVO; 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; @@ -39,6 +42,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; @@ -48,6 +53,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; @@ -59,6 +65,7 @@ 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; @@ -73,6 +80,12 @@ public class NsxServiceImplTest { private NsxVrfGatewayDao nsxVrfGatewayDao; @Mock private DomainDao domainDao; + @Mock + private Site2SiteVpnConnectionDao site2SiteVpnConnectionDao; + @Mock + private Site2SiteVpnGatewayDao site2SiteVpnGatewayDao; + @Mock + private UserIpAddressDetailsDao userIpAddressDetailsDao; NsxServiceImpl nsxService; AutoCloseable closeable; @@ -89,6 +102,9 @@ public void setup() { nsxService.vpcDao = vpcDao; nsxService.nsxVrfGatewayDao = nsxVrfGatewayDao; nsxService.domainDao = domainDao; + nsxService.site2SiteVpnConnectionDao = site2SiteVpnConnectionDao; + nsxService.site2SiteVpnGatewayDao = site2SiteVpnGatewayDao; + nsxService.userIpAddressDetailsDao = userIpAddressDetailsDao; } @After @@ -311,6 +327,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); } @@ -336,6 +353,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); } @@ -346,4 +364,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 4e09f9852e85..9912ae7470ff 100644 --- a/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpn/Site2SiteVpnManagerImpl.java @@ -693,58 +693,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); @@ -805,6 +804,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(); @@ -922,127 +922,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 b23ed42d29b7..99dc75f2463f 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; @@ -65,10 +67,12 @@ import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; 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; @@ -85,8 +89,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; @@ -167,6 +173,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); @@ -675,6 +682,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); @@ -731,7 +788,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); @@ -742,8 +798,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 @@ -779,6 +871,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); @@ -799,6 +905,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); @@ -812,7 +942,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); @@ -825,6 +954,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 a22ead41cf7a791268289e88dff07b262dee1d74 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 05/30] NSX: qualify Mockito retry assertion --- .../java/org/apache/cloudstack/service/NsxApiClientTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java index f046f5db75d6..93742fc9818e 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java @@ -951,7 +951,7 @@ public void testAddVpnConnectionRoutesRetriesMarkedForDeletion() { client.addVpnConnectionRoutes(TIER_1_GATEWAY_NAME, "connection-uuid", List.of("192.168.100.0/24"), "169.254.64.22", "10.1.0.0/16"); - verify(staticRoutes, times(2)).patch(eq(TIER_1_GATEWAY_NAME), + verify(staticRoutes, Mockito.times(2)).patch(eq(TIER_1_GATEWAY_NAME), eq("cs-conn-connection-uuid-route0"), any(com.vmware.nsx_policy.model.StaticRoutes.class)); verify(natRules).patch(eq(TIER_1_GATEWAY_NAME), anyString(), eq("cs-conn-connection-uuid-nosnat0"), any(PolicyNatRule.class)); From 8c22f346850273f9c44dcfb488a9c835e26325bc Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:30:04 +0100 Subject: [PATCH 06/30] NSX: support segment discovery and security profiles --- .../com/cloud/offering/NetworkOffering.java | 4 +- .../agent/api/CreateNsxSegmentCommand.java | 26 ++++++ .../cloudstack/resource/NsxResource.java | 3 +- .../cloudstack/service/NsxApiClient.java | 82 ++++++++++++++++- .../service/NsxGuestNetworkGuru.java | 12 ++- .../apache/cloudstack/utils/NsxHelper.java | 8 +- .../cloudstack/resource/NsxResourceTest.java | 19 ++++ .../cloudstack/service/NsxApiClientTest.java | 92 ++++++++++++++++++- .../service/NsxGuestNetworkGuruTest.java | 25 +++++ .../ConfigurationManagerImpl.java | 20 ++++ .../ConfigurationManagerImplTest.java | 44 +++++++++ ui/public/locales/en.json | 6 ++ ui/src/views/offering/AddNetworkOffering.vue | 38 +++++++- .../views/offering/CloneNetworkOffering.vue | 54 ++++++++++- 14 files changed, 425 insertions(+), 8 deletions(-) diff --git a/api/src/main/java/com/cloud/offering/NetworkOffering.java b/api/src/main/java/com/cloud/offering/NetworkOffering.java index 5000a4f8c626..89463916b93e 100644 --- a/api/src/main/java/com/cloud/offering/NetworkOffering.java +++ b/api/src/main/java/com/cloud/offering/NetworkOffering.java @@ -40,7 +40,9 @@ public enum State { } public enum Detail { - InternalLbProvider, PublicLbProvider, servicepackageuuid, servicepackagedescription, PromiscuousMode, MacAddressChanges, ForgedTransmits, MacLearning, RelatedNetworkOffering, domainid, zoneid, pvlanType, internetProtocol + InternalLbProvider, PublicLbProvider, servicepackageuuid, servicepackagedescription, PromiscuousMode, MacAddressChanges, ForgedTransmits, MacLearning, + NsxIpDiscoveryProfileId, NsxMacDiscoveryProfileId, NsxSegmentSecurityProfileId, + RelatedNetworkOffering, domainid, zoneid, pvlanType, internetProtocol } public enum NetworkMode { diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxSegmentCommand.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxSegmentCommand.java index b4b86bd640a6..e03fb1751190 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxSegmentCommand.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/agent/api/CreateNsxSegmentCommand.java @@ -26,10 +26,21 @@ public class CreateNsxSegmentCommand extends NsxCommand { private String networkName; private String networkGateway; private String networkCidr; + private String ipDiscoveryProfileId; + private String macDiscoveryProfileId; + private String segmentSecurityProfileId; public CreateNsxSegmentCommand(long domainId, long accountId, long zoneId, Long vpcId, String vpcName, long networkId, String networkName, String networkGateway, String networkCidr) { + this(domainId, accountId, zoneId, vpcId, vpcName, networkId, networkName, networkGateway, + networkCidr, null, null, null); + } + + public CreateNsxSegmentCommand(long domainId, long accountId, long zoneId, + Long vpcId, String vpcName, long networkId, String networkName, + String networkGateway, String networkCidr, String ipDiscoveryProfileId, + String macDiscoveryProfileId, String segmentSecurityProfileId) { super(domainId, accountId, zoneId); this.vpcId = vpcId; this.vpcName = vpcName; @@ -37,6 +48,9 @@ public CreateNsxSegmentCommand(long domainId, long accountId, long zoneId, this.networkName = networkName; this.networkGateway = networkGateway; this.networkCidr = networkCidr; + this.ipDiscoveryProfileId = ipDiscoveryProfileId; + this.macDiscoveryProfileId = macDiscoveryProfileId; + this.segmentSecurityProfileId = segmentSecurityProfileId; } public Long getVpcId() { @@ -63,6 +77,18 @@ public String getNetworkCidr() { return networkCidr; } + public String getIpDiscoveryProfileId() { + return ipDiscoveryProfileId; + } + + public String getMacDiscoveryProfileId() { + return macDiscoveryProfileId; + } + + public String getSegmentSecurityProfileId() { + return segmentSecurityProfileId; + } + @Override public boolean equals(Object o) { if (this == o) return true; 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 e2cc3cc03ad5..e598c3a81a82 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 @@ -392,7 +392,8 @@ private Answer executeRequest(CreateNsxSegmentCommand cmd) { boolean isResourceVpc = !Objects.isNull(cmd.getVpcId()); String tier1GatewayName = NsxControllerUtils.getTier1GatewayName(cmd.getDomainId(), cmd.getAccountId(), cmd.getZoneId(), networkResourceId, isResourceVpc); - nsxApiClient.createSegment(segmentName, tier1GatewayName, gatewayAddress, enforcementPointPath, transportZones); + nsxApiClient.createSegment(segmentName, tier1GatewayName, gatewayAddress, enforcementPointPath, transportZones, + cmd.getIpDiscoveryProfileId(), cmd.getMacDiscoveryProfileId(), cmd.getSegmentSecurityProfileId()); nsxApiClient.createGroupForSegment(segmentName); } catch (Exception e) { logger.error(String.format("Failed to create network: %s", cmd.getNetworkName())); diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java index fa1f94813a9d..259ee7f78e88 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 @@ -29,11 +29,14 @@ 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.IpDiscoveryProfiles; import com.vmware.nsx_policy.infra.LbAppProfiles; import com.vmware.nsx_policy.infra.LbMonitorProfiles; 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.MacDiscoveryProfiles; +import com.vmware.nsx_policy.infra.SegmentSecurityProfiles; import com.vmware.nsx_policy.infra.Segments; import com.vmware.nsx_policy.infra.Services; import com.vmware.nsx_policy.infra.Sites; @@ -42,6 +45,8 @@ import com.vmware.nsx_policy.infra.domains.SecurityPolicies; import com.vmware.nsx_policy.infra.domains.groups.members.SegmentPorts; import com.vmware.nsx_policy.infra.domains.security_policies.Rules; +import com.vmware.nsx_policy.infra.segments.SegmentDiscoveryProfileBindingMaps; +import com.vmware.nsx_policy.infra.segments.SegmentSecurityProfileBindingMaps; 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; @@ -68,6 +73,7 @@ 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.IPDiscoveryProfile; import com.vmware.nsx_policy.model.L4PortSetServiceEntry; import com.vmware.nsx_policy.model.LBAppProfileListResult; import com.vmware.nsx_policy.model.LBIcmpMonitorProfile; @@ -80,6 +86,7 @@ import com.vmware.nsx_policy.model.LBVirtualServer; import com.vmware.nsx_policy.model.LBVirtualServerListResult; import com.vmware.nsx_policy.model.LocaleServicesListResult; +import com.vmware.nsx_policy.model.MacDiscoveryProfile; import com.vmware.nsx_policy.model.PathExpression; import com.vmware.nsx_policy.model.PolicyGroupMembersListResult; import com.vmware.nsx_policy.model.PolicyNatRule; @@ -90,6 +97,9 @@ 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.SegmentDiscoveryProfileBindingMap; +import com.vmware.nsx_policy.model.SegmentSecurityProfile; +import com.vmware.nsx_policy.model.SegmentSecurityProfileBindingMap; import com.vmware.nsx_policy.model.SegmentSubnet; import com.vmware.nsx_policy.model.ServiceListResult; import com.vmware.nsx_policy.model.Site; @@ -115,9 +125,10 @@ import org.apache.cloudstack.utils.NsxControllerUtils; import org.apache.cloudstack.utils.NsxVpnCryptoUtils; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; 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; @@ -166,6 +177,8 @@ public class NsxApiClient { private static final String TIER_1_RESOURCE_TYPE = "Tier1"; private static final String TIER_1_LOCALE_SERVICE_ID = "default"; private static final String SEGMENT_RESOURCE_TYPE = "Segment"; + private static final String SEGMENT_DISCOVERY_PROFILE_BINDING_ID = "cloudstack-discovery-profile-binding"; + private static final String SEGMENT_SECURITY_PROFILE_BINDING_ID = "cloudstack-security-profile-binding"; private static final String TIER_0_GATEWAY_PATH_PREFIX = "/infra/tier-0s/"; private static final String TIER_1_GATEWAY_PATH_PREFIX = "/infra/tier-1s/"; protected static final String SEGMENTS_PATH = "/infra/segments"; @@ -669,7 +682,16 @@ public TransportZoneListResult getTransportZones() { public void createSegment(String segmentName, String tier1GatewayName, String gatewayAddress, String enforcementPointPath, List transportZones) { + createSegment(segmentName, tier1GatewayName, gatewayAddress, enforcementPointPath, transportZones, null, null, null); + } + + public void createSegment(String segmentName, String tier1GatewayName, String gatewayAddress, String enforcementPointPath, + List transportZones, String ipDiscoveryProfileId, String macDiscoveryProfileId, + String segmentSecurityProfileId) { try { + String ipDiscoveryProfilePath = getIpDiscoveryProfilePath(ipDiscoveryProfileId); + String macDiscoveryProfilePath = getMacDiscoveryProfilePath(macDiscoveryProfileId); + String segmentSecurityProfilePath = getSegmentSecurityProfilePath(segmentSecurityProfileId); Segments segmentService = (Segments) nsxService.apply(Segments.class); SegmentSubnet subnet = new SegmentSubnet.Builder() .setGatewayAddress(gatewayAddress) @@ -684,6 +706,7 @@ public void createSegment(String segmentName, String tier1GatewayName, String ga .setTransportZonePath(enforcementPointPath + "/transport-zones/" + transportZones.get(0).getId()) .build(); segmentService.patch(segmentName, segment); + bindSegmentProfiles(segmentName, ipDiscoveryProfilePath, macDiscoveryProfilePath, segmentSecurityProfilePath); } catch (Error error) { ApiError ae = error.getData()._convertTo(ApiError.class); String msg = String.format("Error creating segment %s: %s", segmentName, ae.getErrorMessage()); @@ -692,6 +715,63 @@ public void createSegment(String segmentName, String tier1GatewayName, String ga } } + protected String getIpDiscoveryProfilePath(String profileId) { + if (StringUtils.isBlank(profileId)) { + return null; + } + IpDiscoveryProfiles profiles = (IpDiscoveryProfiles) nsxService.apply(IpDiscoveryProfiles.class); + IPDiscoveryProfile profile = profiles.get(profileId); + return validateProfilePath(profileId, profile.getPath()); + } + + protected String getMacDiscoveryProfilePath(String profileId) { + if (StringUtils.isBlank(profileId)) { + return null; + } + MacDiscoveryProfiles profiles = (MacDiscoveryProfiles) nsxService.apply(MacDiscoveryProfiles.class); + MacDiscoveryProfile profile = profiles.get(profileId); + return validateProfilePath(profileId, profile.getPath()); + } + + protected String getSegmentSecurityProfilePath(String profileId) { + if (StringUtils.isBlank(profileId)) { + return null; + } + SegmentSecurityProfiles profiles = (SegmentSecurityProfiles) nsxService.apply(SegmentSecurityProfiles.class); + SegmentSecurityProfile profile = profiles.get(profileId); + return validateProfilePath(profileId, profile.getPath()); + } + + protected String validateProfilePath(String profileId, String profilePath) { + if (StringUtils.isBlank(profilePath)) { + throw new CloudRuntimeException(String.format("NSX profile %s did not return a canonical resource path", profileId)); + } + return profilePath; + } + + protected void bindSegmentProfiles(String segmentName, String ipDiscoveryProfilePath, String macDiscoveryProfilePath, + String segmentSecurityProfilePath) { + if (StringUtils.isNotBlank(ipDiscoveryProfilePath) || StringUtils.isNotBlank(macDiscoveryProfilePath)) { + SegmentDiscoveryProfileBindingMaps discoveryBindings = + (SegmentDiscoveryProfileBindingMaps) nsxService.apply(SegmentDiscoveryProfileBindingMaps.class); + SegmentDiscoveryProfileBindingMap binding = new SegmentDiscoveryProfileBindingMap.Builder() + .setId(SEGMENT_DISCOVERY_PROFILE_BINDING_ID) + .setIpDiscoveryProfilePath(ipDiscoveryProfilePath) + .setMacDiscoveryProfilePath(macDiscoveryProfilePath) + .build(); + discoveryBindings.patch(segmentName, SEGMENT_DISCOVERY_PROFILE_BINDING_ID, binding); + } + if (StringUtils.isNotBlank(segmentSecurityProfilePath)) { + SegmentSecurityProfileBindingMaps securityBindings = + (SegmentSecurityProfileBindingMaps) nsxService.apply(SegmentSecurityProfileBindingMaps.class); + SegmentSecurityProfileBindingMap binding = new SegmentSecurityProfileBindingMap.Builder() + .setId(SEGMENT_SECURITY_PROFILE_BINDING_ID) + .setSegmentSecurityProfilePath(segmentSecurityProfilePath) + .build(); + securityBindings.patch(segmentName, SEGMENT_SECURITY_PROFILE_BINDING_ID, binding); + } + } + public void deleteSegment(long zoneId, long domainId, long accountId, Long vpcId, long networkId, String segmentName) { try { removeSegmentDistributedFirewallRules(segmentName); diff --git a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxGuestNetworkGuru.java b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxGuestNetworkGuru.java index 0f7865d7d73f..6d1c717e90c4 100644 --- a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxGuestNetworkGuru.java +++ b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxGuestNetworkGuru.java @@ -59,6 +59,7 @@ import javax.inject.Inject; import java.util.List; +import java.util.Map; import java.util.Objects; public class NsxGuestNetworkGuru extends GuestNetworkGuru implements NetworkMigrationResponder { @@ -324,10 +325,19 @@ public void createNsxSegment(NetworkVO networkVO, DataCenter zone) { throw new CloudRuntimeException(msg); } } - CreateNsxSegmentCommand command = NsxHelper.createNsxSegmentCommand(domain, account, zone, vpcName, networkVO); + Map offeringDetails = _networkModel.getNtwkOffDetails(networkVO.getNetworkOfferingId()); + String ipDiscoveryProfile = getOfferingDetail(offeringDetails, NetworkOffering.Detail.NsxIpDiscoveryProfileId); + String macDiscoveryProfile = getOfferingDetail(offeringDetails, NetworkOffering.Detail.NsxMacDiscoveryProfileId); + String segmentSecurityProfile = getOfferingDetail(offeringDetails, NetworkOffering.Detail.NsxSegmentSecurityProfileId); + CreateNsxSegmentCommand command = NsxHelper.createNsxSegmentCommand(domain, account, zone, vpcName, networkVO, + ipDiscoveryProfile, macDiscoveryProfile, segmentSecurityProfile); NsxAnswer answer = nsxControllerUtils.sendNsxCommand(command, zone.getId()); if (!answer.getResult()) { throw new CloudRuntimeException("can not create NSX network"); } } + + protected String getOfferingDetail(Map offeringDetails, NetworkOffering.Detail detail) { + return offeringDetails == null ? null : offeringDetails.get(detail); + } } 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 625bd4be8040..78c031fd335b 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 @@ -61,8 +61,14 @@ public static CreateNsxDhcpRelayConfigCommand createNsxDhcpRelayConfigCommand(Do } public static CreateNsxSegmentCommand createNsxSegmentCommand(DomainVO domain, Account account, DataCenter zone, String vpcName, NetworkVO networkVO) { + return createNsxSegmentCommand(domain, account, zone, vpcName, networkVO, null, null, null); + } + + public static CreateNsxSegmentCommand createNsxSegmentCommand(DomainVO domain, Account account, DataCenter zone, String vpcName, NetworkVO networkVO, + String ipDiscoveryProfileId, String macDiscoveryProfileId, String segmentSecurityProfileId) { return new CreateNsxSegmentCommand(domain.getId(), account.getId(), zone.getId(), - networkVO.getVpcId(), vpcName, networkVO.getId(), networkVO.getName(), networkVO.getGateway(), networkVO.getCidr()); + networkVO.getVpcId(), vpcName, networkVO.getId(), networkVO.getName(), networkVO.getGateway(), networkVO.getCidr(), + ipDiscoveryProfileId, macDiscoveryProfileId, segmentSecurityProfileId); } public static CreateOrUpdateNsxTier1NatRuleCommand createOrUpdateNsxNatRuleCommand(long domainId, long accountId, long zoneId, diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/resource/NsxResourceTest.java index 27790354f19a..f74bb49a338e 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 @@ -73,6 +73,7 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -198,6 +199,24 @@ public void testCreateNsxSegment() { assertTrue(answer.getResult()); } + @Test + public void testCreateNsxSegmentPassesProfileIdsToApiClient() { + List transportZoneList = List.of(new TransportZone.Builder().setDisplayName(transportZone).build()); + CreateNsxSegmentCommand command = new CreateNsxSegmentCommand(domainId, accountId, zoneId, + 2L, "VPC01", 3L, "Web", "10.10.10.1", "10.10.10.0/24", + "ip-profile", "mac-profile", "security-profile"); + when(nsxApi.getDefaultSiteId()).thenReturn("site1"); + when(nsxApi.getDefaultEnforcementPointPath("site1")).thenReturn("enforcementPointPath"); + when(nsxApi.getTransportZones()).thenReturn(transportZoneListResult); + when(transportZoneListResult.getResults()).thenReturn(transportZoneList); + + NsxAnswer answer = (NsxAnswer) nsxResource.executeRequest(command); + + assertTrue(answer.getResult()); + verify(nsxApi).createSegment(anyString(), anyString(), anyString(), eq("enforcementPointPath"), + eq(transportZoneList), eq("ip-profile"), eq("mac-profile"), eq("security-profile")); + } + @Test public void testCreateNsxSegmentEmptySites() { when(nsxApi.getDefaultSiteId()).thenReturn(null); diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxApiClientTest.java index 93742fc9818e..a62f4d13fdbf 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 @@ -22,6 +22,7 @@ import com.vmware.nsx.cluster.Status; import com.vmware.nsx.model.ClusterStatus; import com.vmware.nsx.model.ControllerClusterStatus; +import com.vmware.nsx_policy.infra.IpDiscoveryProfiles; import com.vmware.nsx_policy.infra.LbAppProfiles; import com.vmware.nsx_policy.infra.LbMonitorProfiles; import com.vmware.nsx_policy.infra.LbPools; @@ -30,15 +31,21 @@ 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.MacDiscoveryProfiles; +import com.vmware.nsx_policy.infra.SegmentSecurityProfiles; +import com.vmware.nsx_policy.infra.Segments; 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.segments.SegmentDiscoveryProfileBindingMaps; +import com.vmware.nsx_policy.infra.segments.SegmentSecurityProfileBindingMaps; 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.IPDiscoveryProfile; import com.vmware.nsx_policy.model.IPSecVpnDpdProfile; import com.vmware.nsx_policy.model.IPSecVpnIkeProfile; import com.vmware.nsx_policy.model.IPSecVpnSession; @@ -52,10 +59,15 @@ import com.vmware.nsx_policy.model.LBPool; import com.vmware.nsx_policy.model.LBPoolMember; import com.vmware.nsx_policy.model.LBVirtualServer; +import com.vmware.nsx_policy.model.MacDiscoveryProfile; 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.Segment; +import com.vmware.nsx_policy.model.SegmentDiscoveryProfileBindingMap; +import com.vmware.nsx_policy.model.SegmentSecurityProfile; +import com.vmware.nsx_policy.model.SegmentSecurityProfileBindingMap; import com.vmware.nsx_policy.model.StaticRoutesListResult; import com.vmware.nsx_policy.model.Tag; import com.vmware.nsx_policy.model.Tier1; @@ -69,11 +81,11 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockedConstruction; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; -import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import java.util.List; @@ -131,6 +143,84 @@ public void testCreateGroupForSegment() { } } + @Test + public void testCreateSegmentBindsConfiguredProfiles() { + Segments segmentService = Mockito.mock(Segments.class); + IpDiscoveryProfiles ipProfiles = Mockito.mock(IpDiscoveryProfiles.class); + MacDiscoveryProfiles macProfiles = Mockito.mock(MacDiscoveryProfiles.class); + SegmentSecurityProfiles securityProfiles = Mockito.mock(SegmentSecurityProfiles.class); + SegmentDiscoveryProfileBindingMaps discoveryBindings = Mockito.mock(SegmentDiscoveryProfileBindingMaps.class); + SegmentSecurityProfileBindingMaps securityBindings = Mockito.mock(SegmentSecurityProfileBindingMaps.class); + IPDiscoveryProfile ipProfile = Mockito.mock(IPDiscoveryProfile.class); + MacDiscoveryProfile macProfile = Mockito.mock(MacDiscoveryProfile.class); + SegmentSecurityProfile securityProfile = Mockito.mock(SegmentSecurityProfile.class); + when(nsxService.apply(Segments.class)).thenReturn(segmentService); + when(nsxService.apply(IpDiscoveryProfiles.class)).thenReturn(ipProfiles); + when(nsxService.apply(MacDiscoveryProfiles.class)).thenReturn(macProfiles); + when(nsxService.apply(SegmentSecurityProfiles.class)).thenReturn(securityProfiles); + when(nsxService.apply(SegmentDiscoveryProfileBindingMaps.class)).thenReturn(discoveryBindings); + when(nsxService.apply(SegmentSecurityProfileBindingMaps.class)).thenReturn(securityBindings); + when(ipProfiles.get("ip-profile")).thenReturn(ipProfile); + when(macProfiles.get("mac-profile")).thenReturn(macProfile); + when(securityProfiles.get("security-profile")).thenReturn(securityProfile); + when(ipProfile.getPath()).thenReturn("/infra/ip-discovery-profiles/ip-profile"); + when(macProfile.getPath()).thenReturn("/infra/mac-discovery-profiles/mac-profile"); + when(securityProfile.getPath()).thenReturn("/infra/segment-security-profiles/security-profile"); + ArgumentCaptor discoveryCaptor = + ArgumentCaptor.forClass(SegmentDiscoveryProfileBindingMap.class); + ArgumentCaptor securityCaptor = + ArgumentCaptor.forClass(SegmentSecurityProfileBindingMap.class); + + client.createSegment("segment", "tier1", "10.10.10.1/24", "/infra/sites/default/enforcement-points/default", + List.of(new com.vmware.nsx.model.TransportZone.Builder().setId("tz").build()), + "ip-profile", "mac-profile", "security-profile"); + + verify(segmentService).patch(eq("segment"), any(Segment.class)); + verify(discoveryBindings).patch(eq("segment"), anyString(), discoveryCaptor.capture()); + verify(securityBindings).patch(eq("segment"), anyString(), securityCaptor.capture()); + Assert.assertEquals("/infra/ip-discovery-profiles/ip-profile", discoveryCaptor.getValue().getIpDiscoveryProfilePath()); + Assert.assertEquals("/infra/mac-discovery-profiles/mac-profile", discoveryCaptor.getValue().getMacDiscoveryProfilePath()); + Assert.assertEquals("/infra/segment-security-profiles/security-profile", + securityCaptor.getValue().getSegmentSecurityProfilePath()); + } + + @Test + public void testCreateSegmentWithoutProfilesPreservesExistingBehavior() { + Segments segmentService = Mockito.mock(Segments.class); + when(nsxService.apply(Segments.class)).thenReturn(segmentService); + + client.createSegment("segment", "tier1", "10.10.10.1/24", "/infra/sites/default/enforcement-points/default", + List.of(new com.vmware.nsx.model.TransportZone.Builder().setId("tz").build())); + + verify(segmentService).patch(eq("segment"), any(Segment.class)); + verify(nsxService, never()).apply(IpDiscoveryProfiles.class); + verify(nsxService, never()).apply(MacDiscoveryProfiles.class); + verify(nsxService, never()).apply(SegmentSecurityProfiles.class); + verify(nsxService, never()).apply(SegmentDiscoveryProfileBindingMaps.class); + verify(nsxService, never()).apply(SegmentSecurityProfileBindingMaps.class); + } + + @Test + public void testCreateSegmentRejectsProfileWithoutCanonicalPathBeforeCreatingSegment() { + Segments segmentService = Mockito.mock(Segments.class); + IpDiscoveryProfiles ipProfiles = Mockito.mock(IpDiscoveryProfiles.class); + IPDiscoveryProfile ipProfile = Mockito.mock(IPDiscoveryProfile.class); + when(nsxService.apply(Segments.class)).thenReturn(segmentService); + when(nsxService.apply(IpDiscoveryProfiles.class)).thenReturn(ipProfiles); + when(ipProfiles.get("ip-profile")).thenReturn(ipProfile); + when(ipProfile.getPath()).thenReturn(" "); + + Assert.assertThrows(CloudRuntimeException.class, + () -> client.createSegment("segment", "tier1", "10.10.10.1/24", + "/infra/sites/default/enforcement-points/default", + List.of(new com.vmware.nsx.model.TransportZone.Builder().setId("tz").build()), + "ip-profile", null, null)); + + verify(segmentService, never()).patch(anyString(), any(Segment.class)); + verify(nsxService, never()).apply(SegmentDiscoveryProfileBindingMaps.class); + verify(nsxService, never()).apply(SegmentSecurityProfileBindingMaps.class); + } + @Test public void testGetGroupsForTrafficIngress() { SDNProviderNetworkRule rule = Mockito.mock(SDNProviderNetworkRule.class); diff --git a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxGuestNetworkGuruTest.java b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxGuestNetworkGuruTest.java index cb79873f364d..251e5214e43f 100644 --- a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxGuestNetworkGuruTest.java +++ b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/NsxGuestNetworkGuruTest.java @@ -58,6 +58,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.Mockito; @@ -66,6 +67,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.util.Map; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertEquals; @@ -304,6 +306,29 @@ public void testCreateNsxSegmentForVpc() { anyLong()); } + @Test + public void testCreateNsxSegmentPassesNetworkOfferingProfiles() { + NetworkVO networkVO = Mockito.mock(NetworkVO.class); + DataCenter dataCenter = Mockito.mock(DataCenter.class); + when(networkVO.getAccountId()).thenReturn(1L); + when(networkVO.getNetworkOfferingId()).thenReturn(42L); + when(networkModel.getNtwkOffDetails(42L)).thenReturn(Map.of( + NetworkOffering.Detail.NsxIpDiscoveryProfileId, "ip-profile", + NetworkOffering.Detail.NsxMacDiscoveryProfileId, "mac-profile", + NetworkOffering.Detail.NsxSegmentSecurityProfileId, "security-profile")); + when(nsxControllerUtils.sendNsxCommand(any(CreateNsxSegmentCommand.class), anyLong())) + .thenReturn(new NsxAnswer(new NsxCommand(), true, "")); + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(CreateNsxSegmentCommand.class); + + guru.createNsxSegment(networkVO, dataCenter); + + verify(nsxControllerUtils).sendNsxCommand(commandCaptor.capture(), anyLong()); + CreateNsxSegmentCommand command = commandCaptor.getValue(); + assertEquals("ip-profile", command.getIpDiscoveryProfileId()); + assertEquals("mac-profile", command.getMacDiscoveryProfileId()); + assertEquals("security-profile", command.getSegmentSecurityProfileId()); + } + @Test public void testCreateNsxSegmentForIsolatedNetwork() { diff --git a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java index 837254ed8b36..ae03615cc78d 100644 --- a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java +++ b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java @@ -572,6 +572,9 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati private static final String DefaultVlanForPodIpRange = Vlan.UNTAGGED; private static final Set VPC_ONLY_PROVIDERS = Sets.newHashSet(Provider.VPCVirtualRouter, Provider.JuniperContrailVpcRouter, Provider.InternalLbVm); + private static final Set NSX_SEGMENT_PROFILE_DETAILS = Set.of(Detail.NsxIpDiscoveryProfileId, + Detail.NsxMacDiscoveryProfileId, Detail.NsxSegmentSecurityProfileId); + private static final int MAX_NSX_PROFILE_ID_LENGTH = 255; private static final List SUPPORTED_ROUTING_MODE_STRS = Arrays.asList(Static.toString().toLowerCase(), Dynamic.toString().toLowerCase()); private static final long GiB_TO_BYTES = 1024 * 1024 * 1024; @@ -7987,8 +7990,25 @@ boolean isSharedSourceNat(Map> serviceProviderMap, Map details, final Map> serviceProviderMap) { + boolean nsxProviderConfigured = serviceProviderMap != null && serviceProviderMap.values().stream() + .filter(Objects::nonNull) + .anyMatch(providers -> providers.contains(Provider.Nsx)); for (final Detail detail : details.keySet()) { + if (NSX_SEGMENT_PROFILE_DETAILS.contains(detail)) { + String profileId = details.get(detail); + if (!nsxProviderConfigured) { + throw new InvalidParameterValueException(String.format("Detail %s is supported only by NSX network offerings", detail)); + } + if (StringUtils.isBlank(profileId)) { + throw new InvalidParameterValueException(String.format("A non-empty NSX profile ID is required for detail %s", detail)); + } + if (profileId.length() > MAX_NSX_PROFILE_ID_LENGTH) { + throw new InvalidParameterValueException(String.format("NSX profile ID for detail %s cannot exceed %d characters", + detail, MAX_NSX_PROFILE_ID_LENGTH)); + } + } + Provider lbProvider = null; if (detail == NetworkOffering.Detail.InternalLbProvider || detail == NetworkOffering.Detail.PublicLbProvider) { // 1) Vaidate the detail values - have to match the lb provider diff --git a/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java b/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java index 9a0b150780e4..649f96021e51 100644 --- a/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java +++ b/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java @@ -1410,4 +1410,48 @@ public void testGetExternalNetworkProviderReturnsNullWhenNoExternalProviders() { mapWithEmptySet.put(Network.Service.Firewall, Collections.emptySet()); Assert.assertNull(ConfigurationManagerImpl.getExternalNetworkProvider(null, mapWithEmptySet)); } + + @Test + public void testValidateNetworkOfferingDetailsAcceptsNsxSegmentProfilesForNsxOffering() { + Map details = Map.of( + NetworkOffering.Detail.NsxIpDiscoveryProfileId, "cloudstack-ip-discovery", + NetworkOffering.Detail.NsxMacDiscoveryProfileId, "cloudstack-mac-discovery", + NetworkOffering.Detail.NsxSegmentSecurityProfileId, "cloudstack-segment-security"); + Map> serviceProviderMap = Map.of( + Network.Service.Connectivity, Set.of(Network.Provider.Nsx)); + + configurationManagerImplSpy.validateNtwkOffDetails(details, serviceProviderMap); + } + + @Test + public void testValidateNetworkOfferingDetailsRejectsNsxSegmentProfileForNonNsxOffering() { + Map details = Map.of( + NetworkOffering.Detail.NsxIpDiscoveryProfileId, "cloudstack-ip-discovery"); + Map> serviceProviderMap = Map.of( + Network.Service.Connectivity, Set.of(Network.Provider.VPCVirtualRouter)); + + Assert.assertThrows(InvalidParameterValueException.class, + () -> configurationManagerImplSpy.validateNtwkOffDetails(details, serviceProviderMap)); + } + + @Test + public void testValidateNetworkOfferingDetailsRejectsBlankNsxSegmentProfile() { + Map details = Map.of(NetworkOffering.Detail.NsxMacDiscoveryProfileId, " "); + Map> serviceProviderMap = Map.of( + Network.Service.Connectivity, Set.of(Network.Provider.Nsx)); + + Assert.assertThrows(InvalidParameterValueException.class, + () -> configurationManagerImplSpy.validateNtwkOffDetails(details, serviceProviderMap)); + } + + @Test + public void testValidateNetworkOfferingDetailsRejectsOversizedNsxSegmentProfile() { + Map details = Map.of( + NetworkOffering.Detail.NsxSegmentSecurityProfileId, "x".repeat(256)); + Map> serviceProviderMap = Map.of( + Network.Service.Connectivity, Set.of(Network.Provider.Nsx)); + + Assert.assertThrows(InvalidParameterValueException.class, + () -> configurationManagerImplSpy.validateNtwkOffDetails(details, serviceProviderMap)); + } } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index d7513c9f2c13..2418662364bb 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1880,6 +1880,9 @@ "label.nsx.provider.edgecluster": "NSX provider edge Cluster", "label.nsx.provider.tier0gateway": "NSX provider tier-0 gateway", "label.nsx.provider.transportzone": "NSX provider transport Zone", +"label.nsx.ip.discovery.profile.id": "NSX IP discovery profile ID", +"label.nsx.mac.discovery.profile.id": "NSX MAC discovery profile ID", +"label.nsx.segment.security.profile.id": "NSX segment security profile ID", "label.nsx.supports.internal.lb": "Enable NSX internal LB service", "label.nsx.supports.lb": "Enable NSX LB service", "label.num.cpu.cores": "# of CPU cores", @@ -3891,6 +3894,9 @@ "message.network.offering.mac.address.changes": "Applicable for guest Networks on VMware hypervisor only.\nReject - If the guest OS changes the effective MAC address of the Instance to a value that is different from the MAC address of the Instance Network adapter (set in the .vmx configuration file), the switch drops all inbound frames to the adapter.\nIf the guest OS changes the effective MAC address of the Instance back to the MAC address of the Instance Network adapter, the virtual machine receives frames again.\nAccept - If the guest OS changes the effective MAC address of the virtual machine to a value that is different from the MAC address of the Instance Network adapter, the switch allows frames to the new address to pass.\nNone - Default to value from global setting.", "message.network.offering.mac.learning": "Applicable for guest Networks on VMware hypervisor only with VMware Distributed Virtual Switches version 6.6.0 & above and vSphere version 6.7 & above.\nMAC learning enables Network connectivity for multiple MAC addresses behind a single vNIC.\nNone - Default to value from global setting.", "message.network.offering.mac.learning.warning": "WARNING: In order to use MAC Learning you must ensure your hypervisor hosts are running ESXi 6.7+ and the Network uses distributed vSwitch 6.6.0+.", +"message.network.offering.nsx.ip.discovery.profile.id": "ID of an existing NSX IP discovery profile to bind to segments created from this offering.", +"message.network.offering.nsx.mac.discovery.profile.id": "ID of an existing NSX MAC discovery profile to bind to segments created from this offering.", +"message.network.offering.nsx.segment.security.profile.id": "ID of an existing NSX segment security profile to bind to segments created from this offering.", "message.network.offering.promiscuous.mode": "Applicable for guest Networks on VMware hypervisor only.\nReject - The switch drops any outbound frame from a virtual machine adapter with a source MAC address that is different from the one in the .vmx configuration file.\nAccept - The switch does not perform filtering, and permits all outbound frames.\nNone - Default to value from global setting.", "message.network.removenic": "Please confirm that want to remove this NIC, which will also remove the associated Network from the Instance.", "message.network.restart.required": "Restart is required for network(s). Click here to view network(s) which require restart.", diff --git a/ui/src/views/offering/AddNetworkOffering.vue b/ui/src/views/offering/AddNetworkOffering.vue index 995b81ce68c6..b1b47f87478b 100644 --- a/ui/src/views/offering/AddNetworkOffering.vue +++ b/ui/src/views/offering/AddNetworkOffering.vue @@ -160,6 +160,38 @@ + + + + + + + + + + + + + + + + + + + +