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 5dbb200d32a..605ff28d50e 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -1337,8 +1337,21 @@ public class ApiConstants { public static final String NAME_SERVERS = "nameservers"; public static final String CREDENTIALS = "credentials"; public static final String DNS_ZONE_ID = "dnszoneid"; + public static final String DNS_SERVER_ID = "dnsserverid"; + public static final String CONTENT = "content"; public static final String CONTENTS = "contents"; public static final String PUBLIC_DOMAIN_SUFFIX = "publicdomainsuffix"; + public static final String AUTHORITATIVE = "authoritative"; + public static final String KIND = "kind"; + public static final String DNS_SEC = "dnssec"; + public static final String TTL = "ttl"; + public static final String CHANGE_TYPE = "changetype"; + public static final String RECORDS = "records"; + public static final String RR_SETS = "rrsets"; + public static final String X_API_KEY = "X-API-Key"; + public static final String DISABLED = "disabled"; + public static final String CONTENT_TYPE = "Content-Type"; + public static final String PARAMETER_DESCRIPTION_ACTIVATION_RULE = "Quota tariff's activation rule. It can receive a JS script that results in either " + "a boolean or a numeric value: if it results in a boolean value, the tariff value will be applied according to the result; if it results in a numeric value, the " + diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsZoneCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsZoneCmd.java index 467b0c78796..ab0ac9cf4c3 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsZoneCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsZoneCmd.java @@ -51,7 +51,7 @@ public class CreateDnsZoneCmd extends BaseAsyncCreateCmd { description = "The name of the DNS zone (e.g. example.com)") private String name; - @Parameter(name = "dnsserverid", type = CommandType.UUID, entityType = DnsServerResponse.class, + @Parameter(name = ApiConstants.DNS_SERVER_ID, type = CommandType.UUID, entityType = DnsServerResponse.class, required = true, description = "The ID of the DNS server to host this zone") private Long dnsServerId; diff --git a/api/src/main/java/org/apache/cloudstack/dns/DnsProvider.java b/api/src/main/java/org/apache/cloudstack/dns/DnsProvider.java index b9f4d733975..7d4ab1133b7 100644 --- a/api/src/main/java/org/apache/cloudstack/dns/DnsProvider.java +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsProvider.java @@ -19,6 +19,8 @@ package org.apache.cloudstack.dns; import java.util.List; +import org.apache.cloudstack.dns.exception.DnsProviderException; + import com.cloud.utils.component.Adapter; public interface DnsProvider extends Adapter { @@ -28,11 +30,12 @@ public interface DnsProvider extends Adapter { void validate(DnsServer server) throws Exception; // Zone Operations - String provisionZone(DnsServer server, DnsZone zone); - void deleteZone(DnsServer server, DnsZone zone) ; + String provisionZone(DnsServer server, DnsZone zone) throws DnsProviderException; + void deleteZone(DnsServer server, DnsZone zone) throws DnsProviderException; + void updateZone(DnsServer server, DnsZone zone) throws DnsProviderException; - void addRecord(DnsServer server, DnsZone zone, DnsRecord record); - List listRecords(DnsServer server, DnsZone zone); - void updateRecord(DnsServer server, DnsZone zone, DnsRecord record); - void deleteRecord(DnsServer server, DnsZone zone, DnsRecord record); + String addRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException; + List listRecords(DnsServer server, DnsZone zone) throws DnsProviderException; + String updateRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException; + void deleteRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException; } diff --git a/api/src/main/java/org/apache/cloudstack/dns/DnsProviderManager.java b/api/src/main/java/org/apache/cloudstack/dns/DnsProviderManager.java index e3b7431803f..2adb1d8f3c5 100644 --- a/api/src/main/java/org/apache/cloudstack/dns/DnsProviderManager.java +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsProviderManager.java @@ -61,7 +61,6 @@ public interface DnsProviderManager extends Manager, PluggableService { DnsZone updateDnsZone(UpdateDnsZoneCmd cmd); boolean deleteDnsZone(Long id); ListResponse listDnsZones(ListDnsZonesCmd cmd); - DnsZone getDnsZone(long id); DnsRecordResponse createDnsRecord(CreateDnsRecordCmd cmd); boolean deleteDnsRecord(DeleteDnsRecordCmd cmd); diff --git a/api/src/main/java/org/apache/cloudstack/dns/DnsServer.java b/api/src/main/java/org/apache/cloudstack/dns/DnsServer.java index 617352cbfe3..14160cdf1ff 100644 --- a/api/src/main/java/org/apache/cloudstack/dns/DnsServer.java +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsServer.java @@ -18,11 +18,13 @@ package org.apache.cloudstack.dns; import java.util.Date; +import java.util.List; +import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.api.Identity; import org.apache.cloudstack.api.InternalIdentity; -public interface DnsServer extends InternalIdentity, Identity { +public interface DnsServer extends InternalIdentity, Identity, ControlledEntity { enum State { Enabled, Disabled }; @@ -33,7 +35,7 @@ public interface DnsServer extends InternalIdentity, Identity { DnsProviderType getProviderType(); - String getNameServers(); + List getNameServers(); String getApiKey(); diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsAuthenticationException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsAuthenticationException.java new file mode 100644 index 00000000000..325cb78241e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsAuthenticationException.java @@ -0,0 +1,27 @@ +// 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.dns.exception; + +/** + * Thrown when authentication to the DNS provider fails. + */ +public class DnsAuthenticationException extends DnsProviderException { + public DnsAuthenticationException(String message) { + super(message); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsConflictException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsConflictException.java new file mode 100644 index 00000000000..9a36bb87478 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsConflictException.java @@ -0,0 +1,27 @@ +// 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.dns.exception; + +/** + * Thrown when attempting to create a zone or record that already exists. + */ +public class DnsConflictException extends DnsProviderException { + public DnsConflictException(String message) { + super(message); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsNotFoundException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsNotFoundException.java new file mode 100644 index 00000000000..aa88f308ce8 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsNotFoundException.java @@ -0,0 +1,27 @@ +// 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.dns.exception; + +/** + * Thrown when the requested zone or record does not exist. + */ +public class DnsNotFoundException extends DnsProviderException { + public DnsNotFoundException(String message) { + super(message); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsOperationException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsOperationException.java new file mode 100644 index 00000000000..564acdc9a6f --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsOperationException.java @@ -0,0 +1,27 @@ +// 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.dns.exception; + +/** + * Thrown for unexpected or unknown errors returned by the DNS provider. + */ +public class DnsOperationException extends DnsProviderException { + public DnsOperationException(String message) { + super(message); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsProviderException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsProviderException.java new file mode 100644 index 00000000000..de307c9903e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsProviderException.java @@ -0,0 +1,28 @@ +// 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.dns.exception; + +public class DnsProviderException extends Exception { + public DnsProviderException(String message) { + super(message); + } + + public DnsProviderException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsTransportException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsTransportException.java new file mode 100644 index 00000000000..50f04143c92 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsTransportException.java @@ -0,0 +1,30 @@ +// 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.dns.exception; + +import java.io.IOException; + +/** + * Thrown when HTTP or network errors occur communicating with the DNS provider. + */ +public class DnsTransportException extends DnsProviderException { + + public DnsTransportException(String message, IOException cause) { + super(message, cause); + } +} 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 06054c7cce8..208991bf4b3 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 @@ -68,6 +68,7 @@ CREATE TABLE `cloud`.`dns_server` ( `is_public` tinyint(1) NOT NULL DEFAULT '0', `public_domain_suffix` VARCHAR(255), `state` ENUM('Enabled', 'Disabled') NOT NULL DEFAULT 'Disabled', + `domain_id` bigint unsigned COMMENT 'for domain-specific ownership', `account_id` bigint(20) unsigned NOT NULL, `created` datetime NOT NULL COMMENT 'date created', `removed` datetime DEFAULT NULL COMMENT 'Date removed (soft delete)', diff --git a/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsClient.java b/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsClient.java index b7c1a79e377..28be4f5155e 100644 --- a/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsClient.java +++ b/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsClient.java @@ -20,28 +20,34 @@ package org.apache.cloudstack.dns.powerdns; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.TimeUnit; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.dns.exception.DnsAuthenticationException; +import org.apache.cloudstack.dns.exception.DnsConflictException; +import org.apache.cloudstack.dns.exception.DnsNotFoundException; +import org.apache.cloudstack.dns.exception.DnsOperationException; +import org.apache.cloudstack.dns.exception.DnsProviderException; +import org.apache.cloudstack.dns.exception.DnsTransportException; import org.apache.commons.collections.CollectionUtils; -import org.apache.http.HttpStatus; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPatch; import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.cloud.utils.StringUtils; -import com.cloud.utils.exception.CloudRuntimeException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -50,359 +56,250 @@ import com.fasterxml.jackson.databind.node.ObjectNode; public class PowerDnsClient implements AutoCloseable { public static final Logger logger = LoggerFactory.getLogger(PowerDnsClient.class); private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final int TIMEOUT_MS = 5000; + + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int SOCKET_TIMEOUT_MS = 10_000; + private static final int MAX_CONNECTIONS_TOTAL = 50; + private static final int MAX_CONNECTIONS_PER_ROUTE = 10; + private static final String API_PREFIX = "/api/v1"; + private static final String DEFAULT_SERVER = "localhost"; + private final CloseableHttpClient httpClient; - public void validate(String baseUrl, String apiKey) { - String checkUrl = buildApiUrl(baseUrl, "/servers"); - HttpGet request = new HttpGet(checkUrl); - request.addHeader("X-API-Key", apiKey); - request.addHeader("Content-Type", "application/json"); - request.addHeader("Accept", "application/json"); - - try (CloseableHttpResponse response = httpClient.execute(request)) { - int statusCode = response.getStatusLine().getStatusCode(); - String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : null; - - if (statusCode == HttpStatus.SC_OK) { - JsonNode root = MAPPER.readTree(body); - - if (!root.isArray() || root.isEmpty()) { - throw new CloudRuntimeException("No servers returned by PowerDNS API"); - } - - boolean authoritativeFound = false; - for (JsonNode node : root) { - if ("authoritative".equalsIgnoreCase(node.path("daemon_type").asText(null))) { - authoritativeFound = true; - break; - } - } - - if (!authoritativeFound) { - throw new CloudRuntimeException("No authoritative PowerDNS server found"); - } - - } else if (statusCode == HttpStatus.SC_UNAUTHORIZED || statusCode == HttpStatus.SC_FORBIDDEN) { - throw new CloudRuntimeException("Invalid PowerDNS API key"); - } else { - logger.debug("Unexpected PowerDNS response: HTTP {} Body: {}", statusCode, body); - throw new CloudRuntimeException(String.format("PowerDNS validation failed with HTTP %d", statusCode)); - } - - } catch (IOException ex) { - throw new CloudRuntimeException("Failed to connect to PowerDNS", ex); - } - } - - public String createZone(String baseUrl, String apiKey, String zoneName, String nameServers) { - String normalizedZone = formatZoneName(zoneName); - try { - String url = buildApiUrl(baseUrl, "/servers/localhost/zones"); - ObjectNode json = MAPPER.createObjectNode(); - json.put("name", normalizedZone); - json.put("kind", "Native"); - json.put("dnssec", false); - - if (StringUtils.isNotEmpty(nameServers)) { - List nsNames = new ArrayList<>(Arrays.asList(nameServers.split(","))); - if (!CollectionUtils.isEmpty(nsNames)) { - ArrayNode nsArray = json.putArray("nameservers"); - for (String ns : nsNames) { - nsArray.add(ns.endsWith(".") ? ns : ns + "."); - } - } - } - HttpPost request = new HttpPost(url); - request.addHeader("X-API-Key", apiKey); - request.addHeader("Content-Type", "application/json"); - request.addHeader("Accept", "application/json"); - request.setEntity(new StringEntity(json.toString())); - - try (CloseableHttpResponse response = httpClient.execute(request)) { - - int statusCode = response.getStatusLine().getStatusCode(); - String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : null; - - if (statusCode == HttpStatus.SC_CREATED) { - JsonNode root = MAPPER.readTree(body); - String zoneId = root.path("id").asText(); - if (StringUtils.isBlank(zoneId)) { - throw new CloudRuntimeException("PowerDNS returned empty zone id"); - } - return zoneId; - } - - if (statusCode == HttpStatus.SC_CONFLICT) { - throw new CloudRuntimeException("Zone already exists: " + zoneName); - } - - if (statusCode == HttpStatus.SC_UNAUTHORIZED || statusCode == HttpStatus.SC_FORBIDDEN) { - throw new CloudRuntimeException("Invalid PowerDNS API key"); - } - - logger.debug("Unexpected PowerDNS response: HTTP {} Body: {}", statusCode, body); - throw new CloudRuntimeException(String.format("Failed to create zone %s (HTTP %d)", zoneName, statusCode)); - } - } catch (IOException e) { - throw new CloudRuntimeException("Error while creating PowerDNS zone " + zoneName, e); - } - } - - public void deleteZone(String baseUrl, String apiKey, String zoneName) { - String normalizedZone = formatZoneName(zoneName); - try { - String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); - String url = buildApiUrl(baseUrl, "/servers/localhost/zones/" + encodedZone); - HttpDelete request = new HttpDelete(url); - request.addHeader("X-API-Key", apiKey); - request.addHeader("Content-Type", "application/json"); - request.addHeader("Accept", "application/json"); - - try (CloseableHttpResponse response = httpClient.execute(request)) { - - int statusCode = response.getStatusLine().getStatusCode(); - String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : null; - - if (statusCode == HttpStatus.SC_NO_CONTENT) { - logger.debug("Zone {} deleted successfully", normalizedZone); - return; - } - - if (statusCode == HttpStatus.SC_NOT_FOUND) { - logger.debug("Zone {} not found in PowerDNS", normalizedZone); - return; - } - - if (statusCode == HttpStatus.SC_UNAUTHORIZED || statusCode == HttpStatus.SC_FORBIDDEN) { - throw new CloudRuntimeException("Invalid PowerDNS API key"); - } - - logger.debug("Unexpected PowerDNS response while deleting zone: HTTP {} Body: {}", statusCode, body); - throw new CloudRuntimeException(String.format("Failed to delete zone %s (HTTP %d)", normalizedZone, statusCode)); - } - } catch (IOException e) { - throw new CloudRuntimeException("Error while deleting PowerDNS zone " + zoneName, e); - } - } - - public void modifyRecord(String baseUrl, String apiKey, String zoneName, String recordName, String type, long ttl, List contents, String changeType) { - String normalizedZone = formatZoneName(zoneName); - String normalizedRecord = formatRecordName(recordName, zoneName); - - try { - String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); - String url = buildApiUrl(baseUrl, "/servers/localhost/zones/" + encodedZone); - - ObjectNode root = MAPPER.createObjectNode(); - ArrayNode rrsets = root.putArray("rrsets"); - ObjectNode rrset = rrsets.addObject(); - - rrset.put("name", normalizedRecord); - rrset.put("type", type.toUpperCase()); - rrset.put("ttl", ttl); - rrset.put("changetype", changeType); - - ArrayNode records = rrset.putArray("records"); - if (!CollectionUtils.isEmpty(contents)) { - for (String content : contents) { - ObjectNode record = records.addObject(); - record.put("content", content); - record.put("disabled", false); - } - } - - HttpPatch request = new HttpPatch(url); - request.addHeader("X-API-Key", apiKey); - request.addHeader("Content-Type", "application/json"); - request.addHeader("Accept", "application/json"); - request.setEntity(new StringEntity(root.toString())); - - try (CloseableHttpResponse response = httpClient.execute(request)) { - int statusCode = response.getStatusLine().getStatusCode(); - String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : null; - - if (statusCode == HttpStatus.SC_NO_CONTENT) { - logger.debug("Record {} {} added/updated in zone {}", normalizedRecord, type, normalizedZone); - return; - } - - if (statusCode == HttpStatus.SC_NOT_FOUND) { - throw new CloudRuntimeException("Zone not found: " + normalizedZone); - } - - if (statusCode == HttpStatus.SC_UNAUTHORIZED || statusCode == HttpStatus.SC_FORBIDDEN) { - throw new CloudRuntimeException("Invalid PowerDNS API key"); - } - - logger.debug("Unexpected PowerDNS response: HTTP {} Body: {}", statusCode, body); - throw new CloudRuntimeException("Failed to add/update record " + normalizedRecord); - } - - } catch (IOException e) { - throw new CloudRuntimeException("Error while adding PowerDNS record", e); - } - } - - public void deleteRecord(String baseUrl, String apiKey, String zoneName, String recordName, String type) { - - String normalizedZone = formatZoneName(zoneName); - String normalizedRecord = formatRecordName(recordName, zoneName); - - try { - String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); - String url = buildApiUrl(baseUrl, "/servers/localhost/zones/" + encodedZone); - - ObjectNode root = MAPPER.createObjectNode(); - ArrayNode rrsets = root.putArray("rrsets"); - ObjectNode rrset = rrsets.addObject(); - - rrset.put("name", normalizedRecord); - rrset.put("type", type.toUpperCase()); - rrset.put("changetype", "DELETE"); - - HttpPatch request = new HttpPatch(url); - request.addHeader("X-API-Key", apiKey); - request.addHeader("Content-Type", "application/json"); - request.addHeader("Accept", "application/json"); - request.setEntity(new StringEntity(root.toString())); - - try (CloseableHttpResponse response = httpClient.execute(request)) { - int statusCode = response.getStatusLine().getStatusCode(); - String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : null; - - if (statusCode == HttpStatus.SC_NO_CONTENT) { - logger.debug("Record {} {} deleted", normalizedRecord, type); - return; - } - - if (statusCode == HttpStatus.SC_NOT_FOUND) { - logger.debug("Record {} {} not found (idempotent delete)", normalizedRecord, type); - return; - } - - if (statusCode == HttpStatus.SC_UNAUTHORIZED || statusCode == HttpStatus.SC_FORBIDDEN) { - throw new CloudRuntimeException("Invalid PowerDNS API key"); - } - - logger.debug("Unexpected PowerDNS response: HTTP {} Body: {}", statusCode, body); - throw new CloudRuntimeException("Failed to delete record " + normalizedRecord); - } - - } catch (IOException e) { - throw new CloudRuntimeException("Error while deleting PowerDNS record", e); - } - } - - public Iterable listRecords(String baseUrl, String apiKey, String zoneName) { - String normalizedZone = formatZoneName(zoneName); - try { - String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); - String url = buildApiUrl(baseUrl, "/servers/localhost/zones/" + encodedZone); - - HttpGet request = new HttpGet(url); - request.addHeader("X-API-Key", apiKey); - request.addHeader("Accept", "application/json"); - - try (CloseableHttpResponse response = httpClient.execute(request)) { - - int statusCode = response.getStatusLine().getStatusCode(); - String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : null; - - if (statusCode == HttpStatus.SC_OK) { - JsonNode zone = MAPPER.readTree(body); - JsonNode rrsets = zone.path("rrsets"); - - if (rrsets.isArray()) { - return rrsets; - } - - return Collections.emptyList(); - } - - if (statusCode == HttpStatus.SC_NOT_FOUND) { - throw new CloudRuntimeException("Zone not found: " + normalizedZone); - } - - if (statusCode == HttpStatus.SC_UNAUTHORIZED || statusCode == HttpStatus.SC_FORBIDDEN) { - throw new CloudRuntimeException("Invalid PowerDNS API key"); - } - - throw new CloudRuntimeException("Failed to list records for zone " + normalizedZone); - } - - } catch (IOException e) { - throw new CloudRuntimeException("Error while listing PowerDNS records", e); - } - } - public PowerDnsClient() { - RequestConfig config = RequestConfig.custom() - .setConnectTimeout(TIMEOUT_MS) - .setConnectionRequestTimeout(TIMEOUT_MS) - .setSocketTimeout(TIMEOUT_MS) + PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); + connectionManager.setMaxTotal(MAX_CONNECTIONS_TOTAL); + connectionManager.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE); + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(CONNECT_TIMEOUT_MS) + .setConnectionRequestTimeout(CONNECT_TIMEOUT_MS) + .setSocketTimeout(SOCKET_TIMEOUT_MS) .build(); this.httpClient = HttpClientBuilder.create() - .setDefaultRequestConfig(config) + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .evictIdleConnections(30, TimeUnit.SECONDS) .disableCookieManagement() .build(); } - private String normalizeBaseUrl(String baseUrl) { - if (baseUrl == null) { - throw new IllegalArgumentException("PowerDNS base URL cannot be null"); + public void validate(String baseUrl, String apiKey) throws DnsProviderException { + String url = buildUrl(baseUrl, "/servers"); + HttpGet request = new HttpGet(url); + JsonNode servers = execute(request, apiKey, 200); + if (servers == null || !servers.isArray() || servers.isEmpty()) { + throw new DnsOperationException("No servers returned by PowerDNS API"); } - String normalizedUrl = baseUrl.trim(); - if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) { - normalizedUrl = "http://" + normalizedUrl; + boolean authoritativeFound = false; + for (JsonNode server : servers) { + if (ApiConstants.AUTHORITATIVE.equalsIgnoreCase(server.path("daemon_type").asText(null))) { + authoritativeFound = true; + break; + } } - if (normalizedUrl.endsWith("/")) { - normalizedUrl = normalizedUrl.substring(0, normalizedUrl.length() - 1); + if (!authoritativeFound) { + throw new DnsOperationException("No authoritative PowerDNS server found"); } - return normalizedUrl; } - private String formatZoneName(String zoneName) { + public String createZone(String baseUrl, String apiKey, String zoneName, String zoneKind, boolean dnsSecFlag, + List nameServers) throws DnsProviderException { + + validate(baseUrl, apiKey); + + String normalizedZone = normalizeZone(zoneName); + ObjectNode json = MAPPER.createObjectNode(); + json.put(ApiConstants.NAME, normalizedZone); + json.put(ApiConstants.KIND, zoneKind); + json.put(ApiConstants.DNS_SEC, dnsSecFlag); + if (!CollectionUtils.isEmpty(nameServers)) { + ArrayNode nsArray = json.putArray(ApiConstants.NAME_SERVERS); + for (String ns : nameServers) { + nsArray.add(ns.endsWith(".") ? ns : ns + "."); + } + } + HttpPost request = new HttpPost(buildUrl(baseUrl, "/servers/" + DEFAULT_SERVER + "/zones")); + request.setEntity(new StringEntity(json.toString(), StandardCharsets.UTF_8)); + JsonNode response = execute(request, apiKey, 201); + if (response == null) { + throw new DnsOperationException("Empty response from DNS server"); + } + String zoneId = response.path(ApiConstants.ID).asText(); + if (StringUtils.isBlank(zoneId)) { + throw new DnsOperationException("PowerDNS returned empty zone id"); + } + return zoneId; + } + + public void updateZone(String baseUrl, String apiKey, String zoneName, String zoneKind, Boolean dnsSecFlag, + List nameServers) throws DnsProviderException { + + String normalizedZone = normalizeZone(zoneName); + String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); + String url = buildUrl(baseUrl, "/servers/" + DEFAULT_SERVER + "/zones/" + encodedZone); + + ObjectNode json = MAPPER.createObjectNode(); + + if (dnsSecFlag != null) { + json.put(ApiConstants.DNS_SEC, dnsSecFlag); + } + if (StringUtils.isNotBlank(zoneKind)) { + json.put(ApiConstants.KIND, zoneKind); + } + if (!CollectionUtils.isEmpty(nameServers)) { + ArrayNode nsArray = json.putArray(ApiConstants.NAME_SERVERS); + for (String ns : nameServers) { + nsArray.add(ns.endsWith(".") ? ns : ns + "."); + } + } + HttpPatch request = new HttpPatch(url); + request.setEntity(new org.apache.http.entity.StringEntity(json.toString(), StandardCharsets.UTF_8)); + execute(request, apiKey, 204); + } + + public void deleteZone(String baseUrl, String apiKey, String zoneName) throws DnsProviderException { + validate(baseUrl, apiKey); + String normalizedZone = normalizeZone(zoneName); + String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); + HttpDelete request = new HttpDelete(buildUrl(baseUrl, "/servers/" + DEFAULT_SERVER + "/zones/" + encodedZone)); + execute(request, apiKey, 204, 404); + } + + public String modifyRecord(String baseUrl, String apiKey, String zoneName, String recordName, String type, long ttl, + List contents, String changeType) throws DnsProviderException { + + validate(baseUrl, apiKey); + String normalizedZone = normalizeZone(zoneName); + String normalizedRecord = normalizeRecordName(recordName, normalizedZone); + ObjectNode root = MAPPER.createObjectNode(); + ArrayNode rrsets = root.putArray(ApiConstants.RR_SETS); + ObjectNode rrset = rrsets.addObject(); + rrset.put(ApiConstants.NAME, normalizedRecord); + rrset.put(ApiConstants.TYPE, type.toUpperCase()); + rrset.put(ApiConstants.TTL, ttl); + rrset.put(ApiConstants.CHANGE_TYPE, changeType); + ArrayNode records = rrset.putArray(ApiConstants.RECORDS); + if (!CollectionUtils.isEmpty(contents)) { + for (String content : contents) { + ObjectNode record = records.addObject(); + record.put(ApiConstants.CONTENT, content); + record.put(ApiConstants.DISABLED, false); + } + } + String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); + HttpPatch request = new HttpPatch(buildUrl(baseUrl, "/servers/" + DEFAULT_SERVER + "/zones/" + encodedZone)); + request.setEntity(new org.apache.http.entity.StringEntity(root.toString(), StandardCharsets.UTF_8)); + execute(request, apiKey, 204); + return normalizedRecord; + } + + public Iterable listRecords(String baseUrl, String apiKey, String zoneName) throws DnsProviderException { + validate(baseUrl, apiKey); + String normalizedZone = normalizeZone(zoneName); + String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); + HttpGet request = new HttpGet(buildUrl(baseUrl, "/servers/" + DEFAULT_SERVER + "/zones/" + encodedZone)); + JsonNode zoneNode = execute(request, apiKey, 200); + if (zoneNode == null || !zoneNode.has(ApiConstants.RR_SETS)) { + return Collections.emptyList(); + } + JsonNode rrsets = zoneNode.path(ApiConstants.RR_SETS); + return rrsets.isArray() ? rrsets : Collections.emptyList(); + } + + private JsonNode execute(HttpUriRequest request, String apiKey, int... expectedStatus) throws DnsProviderException { + request.addHeader(ApiConstants.X_API_KEY, apiKey); + request.addHeader("Accept", "application/json"); + request.addHeader(ApiConstants.CONTENT_TYPE, "application/json"); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int status = response.getStatusLine().getStatusCode(); + String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : null; + + for (int expected : expectedStatus) { + if (status == expected) { + if (body != null && !body.isEmpty()) { + return MAPPER.readTree(body); + } else { + return null; + } + } + } + if (status == 404) { + throw new DnsNotFoundException("Resource not found: " + body); + } else if (status == 401 || status == 403) { + throw new DnsAuthenticationException("Invalid API key"); + } else if (status == 409) { + throw new DnsConflictException("Conflict: " + body); + } + throw new DnsOperationException("Unexpected PowerDNS response: HTTP " + status + " Body: " + body); + } catch (IOException ex) { + throw new DnsTransportException("Error communicating with PowerDNS", ex); + } + } + + private String buildUrl(String baseUrl, String path) { + return normalizeBaseUrl(baseUrl) + API_PREFIX + path; + } + + private String normalizeBaseUrl(String baseUrl) { + if (baseUrl == null) { + throw new IllegalArgumentException("Base URL cannot be null"); + } + String url = baseUrl.trim(); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "http://" + url; + } + if (url.endsWith("/")) { + url = url.substring(0, url.length() - 1); + } + return url; + } + + private String normalizeZone(String zoneName) { + if (StringUtils.isBlank(zoneName)) { + throw new IllegalArgumentException("Zone name must not be null or empty"); + } String zone = zoneName.trim().toLowerCase(); if (!zone.endsWith(".")) { - zone += "."; + zone = zone + "."; + } + if (zone.length() < 2) { + throw new IllegalArgumentException("Zone name is too short"); } return zone; } - private String formatRecordName(String recordName, String zoneName) { + String normalizeRecordName(String recordName, String zoneName) { if (recordName == null) { - throw new IllegalArgumentException("Record name cannot be null"); + throw new IllegalArgumentException("Record name must not be null"); } - String normalizedZone = formatZoneName(zoneName); - String zoneWithoutDot = normalizedZone.substring(0, normalizedZone.length() - 1); - + String normalizedZone = normalizeZone(zoneName); String name = recordName.trim().toLowerCase(); - - // Root record + // Apex of the zone if (name.equals("@") || name.isEmpty()) { return normalizedZone; } - // Already absolute + String zoneWithoutDot = normalizedZone.substring(0, normalizedZone.length() - 1); + // Already absolute (ends with dot) if (name.endsWith(".")) { + // Check if the record belongs to the zone + if (!name.equals(normalizedZone) && !name.endsWith("." + zoneWithoutDot + ".")) { + throw new IllegalArgumentException( + String.format("Record '%s' does not belong to zone '%s'", recordName, zoneName) + ); + } return name; } - - // Fully qualified but missing trailing dot - if (name.equals(zoneWithoutDot) || name.endsWith("." + zoneWithoutDot)) { + if (name.contains(".")) { return name + "."; } - - // Relative name + // Relative name → append zone return name + "." + normalizedZone; } - private String buildApiUrl(String baseUrl, String path) { - return normalizeBaseUrl(baseUrl) + "/api/v1" + path; - } - @Override public void close() { try { diff --git a/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsProvider.java b/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsProvider.java index 8af0aa9806c..9570a1e5f95 100644 --- a/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsProvider.java +++ b/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsProvider.java @@ -26,6 +26,7 @@ import org.apache.cloudstack.dns.DnsProviderType; import org.apache.cloudstack.dns.DnsRecord; import org.apache.cloudstack.dns.DnsServer; import org.apache.cloudstack.dns.DnsZone; +import org.apache.cloudstack.dns.exception.DnsProviderException; import com.cloud.utils.StringUtils; import com.cloud.utils.component.AdapterBase; @@ -40,54 +41,65 @@ public class PowerDnsProvider extends AdapterBase implements DnsProvider { return DnsProviderType.PowerDNS; } - public void validate(DnsServer server) { + public void validate(DnsServer server) throws DnsProviderException { validateServerParams(server); client.validate(server.getUrl(), server.getApiKey()); } @Override - public String provisionZone(DnsServer server, DnsZone zone) { + public String provisionZone(DnsServer server, DnsZone zone) throws DnsProviderException { validateServerZoneParams(server, zone); - return client.createZone(server.getUrl(), server.getApiKey(), zone.getName(), server.getNameServers()); + return client.createZone(server.getUrl(), + server.getApiKey(), + zone.getName(), + "Native", + false, + server.getNameServers() + ); } @Override - public void deleteZone(DnsServer server, DnsZone zone) { + public void deleteZone(DnsServer server, DnsZone zone) throws DnsProviderException { validateServerZoneParams(server, zone); client.deleteZone(server.getUrl(), server.getApiKey(), zone.getName()); } + @Override + public void updateZone(DnsServer server, DnsZone zone) throws DnsProviderException { + validateServerZoneParams(server, zone); + client.updateZone(server.getUrl(), server.getApiKey(), zone.getName(), "Native", false, server.getNameServers()); + } + public enum ChangeType { REPLACE, DELETE } @Override - public void addRecord(DnsServer server, DnsZone zone, DnsRecord record) { + public String addRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException { validateServerZoneParams(server, zone); - applyRecord(server.getUrl(), server.getApiKey(), zone.getName(), record, ChangeType.REPLACE); + return applyRecord(server.getUrl(), server.getApiKey(), zone.getName(), record, ChangeType.REPLACE); } @Override - public void updateRecord(DnsServer server, DnsZone zone, DnsRecord record) { - addRecord(server, zone, record); + public String updateRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException { + validateServerZoneParams(server, zone); + return addRecord(server, zone, record); } - @Override - public void deleteRecord(DnsServer server, DnsZone zone, DnsRecord record) { + public void deleteRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException { validateServerZoneParams(server, zone); applyRecord(server.getUrl(), server.getApiKey(), zone.getName(), record, ChangeType.DELETE); } - public void applyRecord(String serverUrl, String apiKey, String zoneName, DnsRecord record, ChangeType changeType) { - client.modifyRecord(serverUrl, apiKey, zoneName, record.getName(), record.getType().name(), + public String applyRecord(String serverUrl, String apiKey, String zoneName, DnsRecord record, ChangeType changeType) throws DnsProviderException { + return client.modifyRecord(serverUrl, apiKey, zoneName, record.getName(), record.getType().name(), record.getTtl(), record.getContents(), changeType.name()); } - - @Override - public List listRecords(DnsServer server, DnsZone zone) { + public List listRecords(DnsServer server, DnsZone zone) throws DnsProviderException { + validateServerZoneParams(server, zone); List records = new ArrayList<>(); for (JsonNode rrset: client.listRecords(server.getUrl(), server.getApiKey(), zone.getName())) { String name = rrset.path("name").asText(); @@ -114,7 +126,7 @@ public class PowerDnsProvider extends AdapterBase implements DnsProvider { return records; } - void validateServerZoneParams(DnsServer server, DnsZone zone) { + void validateServerZoneParams(DnsServer server, DnsZone zone) throws DnsProviderException { validateServerParams(server); if (StringUtils.isBlank(zone.getName())) { throw new IllegalArgumentException("Zone name cannot be empty"); @@ -130,8 +142,6 @@ public class PowerDnsProvider extends AdapterBase implements DnsProvider { } } - - @Override public boolean configure(String name, Map params) { if (client == null) { diff --git a/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/powerdns/PowerDnsClientTest.java b/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/powerdns/PowerDnsClientTest.java new file mode 100644 index 00000000000..8c19568bb46 --- /dev/null +++ b/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/powerdns/PowerDnsClientTest.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.dns.powerdns; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.mockito.InjectMocks; + +public class PowerDnsClientTest { + @InjectMocks + PowerDnsClient client = new PowerDnsClient(); + + @Test + public void testNormalizeApexRecord() { + String result = client.normalizeRecordName("@", "example.com"); + assertEquals("example.com.", result); + + result = client.normalizeRecordName("", "example.com"); + assertEquals("example.com.", result); + } + + @Test + public void testNormalizeRelativeRecord() { + String result = client.normalizeRecordName("www", "example.com"); + assertEquals("www.example.com.", result); + + result = client.normalizeRecordName("WWW", "example.com"); // test case-insensitive + assertEquals("www.example.com.", result); + } + + @Test + public void testNormalizeAbsoluteRecordWithinZone() { + String result = client.normalizeRecordName("www.example.com.", "example.com"); + assertEquals("www.example.com.", result); + } + + @Test(expected = IllegalArgumentException.class) + public void testNormalizeAbsoluteRecordOutsideZoneThrows() { + client.normalizeRecordName("other.com.", "example.com"); + } + + @Test + public void testNormalizeDottedNameWithoutTrailingDot() { + String result = client.normalizeRecordName("api.test.com", "example.com"); + assertEquals("api.test.com.", result); + } + + @Test + public void testNormalizeRelativeSubdomain() { + String result = client.normalizeRecordName("mail", "example.com"); + assertEquals("mail.example.com.", result); + } + + @Test(expected = IllegalArgumentException.class) + public void testNormalizeNullRecordNameThrows() { + client.normalizeRecordName(null, "example.com"); + } + + @Test + public void testNormalizeZoneNormalization() { + String result = client.normalizeRecordName("www", "Example.Com"); + assertEquals("www.example.com.", result); + + result = client.normalizeRecordName("www", "example.com."); + assertEquals("www.example.com.", result); + } + +} \ No newline at end of file diff --git a/server/src/main/java/org/apache/cloudstack/dns/DnsProviderManagerImpl.java b/server/src/main/java/org/apache/cloudstack/dns/DnsProviderManagerImpl.java index 7dedc3a8f16..57e4cd14d4c 100644 --- a/server/src/main/java/org/apache/cloudstack/dns/DnsProviderManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/dns/DnsProviderManagerImpl.java @@ -61,6 +61,7 @@ import com.cloud.network.dao.NetworkVO; import com.cloud.user.Account; import com.cloud.user.AccountManager; import com.cloud.utils.Pair; +import com.cloud.utils.StringUtils; import com.cloud.utils.component.ManagerBase; import com.cloud.utils.component.PluggableService; import com.cloud.utils.db.Filter; @@ -161,9 +162,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa } Account caller = CallContext.current().getCallingAccount(); - if (!accountMgr.isRootAdmin(caller.getId()) && dnsServer.getAccountId() != caller.getId()) { - throw new PermissionDeniedException("You do not have permission to update this DNS server."); - } + accountMgr.checkAccess(caller, null, true, dnsServer); boolean validationRequired = false; String originalUrl = dnsServer.getUrl(); @@ -234,9 +233,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa throw new InvalidParameterValueException(String.format("DNS server with ID: %s not found.", dnsServerId)); } Account caller = CallContext.current().getCallingAccount(); - if (!accountMgr.isRootAdmin(caller.getId()) && dnsServer.getAccountId() != caller.getId()) { - throw new PermissionDeniedException("You do not have permission to delete this DNS server."); - } + accountMgr.checkAccess(caller, null, true, dnsServer); return dnsServerDao.remove(dnsServerId); } @@ -248,6 +245,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa response.setUrl(server.getUrl()); response.setProvider(server.getProviderType()); response.setPublic(server.isPublic()); + response.setNameServers(server.getNameServers()); response.setObjectName("dnsserver"); return response; } @@ -270,8 +268,8 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa if (server != null && zone.getState() == DnsZone.State.Active) { try { DnsProvider provider = getProvider(server.getProviderType()); - logger.debug("Deleting DNS zone: {} from provider.", zone.getName()); provider.deleteZone(server, zone); + logger.debug("Deleted DNS zone: {}", zone.getName()); } catch (Exception ex) { logger.error("Failed to delete DNS zone from provider", ex); throw new CloudRuntimeException("Failed to delete DNS zone."); @@ -287,26 +285,32 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa @Override public DnsZone updateDnsZone(UpdateDnsZoneCmd cmd) { - DnsZoneVO zone = dnsZoneDao.findById(cmd.getId()); - if (zone == null) { + DnsZoneVO dnsZone = dnsZoneDao.findById(cmd.getId()); + if (dnsZone == null) { throw new InvalidParameterValueException("DNS zone not found."); } - - // ACL Check Account caller = CallContext.current().getCallingAccount(); - accountMgr.checkAccess(caller, null, true, zone); - - // Update fields + accountMgr.checkAccess(caller, null, true, dnsZone); boolean updated = false; if (cmd.getDescription() != null) { - zone.setDescription(cmd.getDescription()); + dnsZone.setDescription(cmd.getDescription()); updated = true; } if (updated) { - dnsZoneDao.update(zone.getId(), zone); + DnsServerVO server = dnsServerDao.findById(dnsZone.getDnsServerId()); + if (server == null) { + throw new CloudRuntimeException("The underlying DNS server for this DNS zone is missing."); + } + try { + DnsProvider provider = getProvider(server.getProviderType()); + provider.updateZone(server, dnsZone); + } catch (Exception ex) { + logger.error("Failed to update DNS zone: {} on DNS server: {}", dnsZone.getName(), server.getName(), ex); + throw new CloudRuntimeException("Failed to update DNS zone: " + dnsZone.getName()); + } } - return zone; + return dnsZone; } @Override @@ -328,11 +332,6 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa return response; } - @Override - public DnsZone getDnsZone(long id) { - return null; - } - @Override public DnsRecordResponse createDnsRecord(CreateDnsRecordCmd cmd) { DnsZoneVO zone = dnsZoneDao.findById(cmd.getDnsZoneId()); @@ -344,10 +343,10 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa accountMgr.checkAccess(caller, null, true, zone); DnsServerVO server = dnsServerDao.findById(zone.getDnsServerId()); try { - DnsRecord record = new DnsRecord(cmd.getName(), cmd.getType(), cmd.getContents(), cmd.getTtl()); DnsProvider provider = getProvider(server.getProviderType()); - // Add Record via Provider - provider.addRecord(server, zone, record); + DnsRecord record = new DnsRecord(cmd.getName(), cmd.getType(), cmd.getContents(), cmd.getTtl()); + String normalizedRecordName = provider.addRecord(server, zone, record); + record.setName(normalizedRecordName); return createDnsRecordResponse(record); } catch (Exception ex) { logger.error("Failed to add DNS record via provider", ex); @@ -449,8 +448,8 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa } @Override - public DnsZone provisionDnsZone(long zoneId) { - DnsZoneVO dnsZone = dnsZoneDao.findById(zoneId); + public DnsZone provisionDnsZone(long dnsZoneId) { + DnsZoneVO dnsZone = dnsZoneDao.findById(dnsZoneId); if (dnsZone == null) { throw new CloudRuntimeException("DNS zone not found during provisioning"); } @@ -460,11 +459,11 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa String externalReferenceId = provider.provisionZone(server, dnsZone); dnsZone.setExternalReference(externalReferenceId); dnsZone.setState(DnsZone.State.Active); - logger.debug("DNS zone: {} created successfully on DNS server: {} with ID: {}", dnsZone.getName(), server.getName(), zoneId); dnsZoneDao.update(dnsZone.getId(), dnsZone); + logger.debug("DNS zone: {} created successfully on DNS server: {} with ID: {}", dnsZone.getName(), server.getName(), dnsZoneId); } catch (Exception ex) { + dnsZoneDao.remove(dnsZoneId); logger.error("Failed to provision DNS zone: {} on DNS server: {}", dnsZone.getName(), server.getName(), ex); - dnsZoneDao.remove(zoneId); throw new CloudRuntimeException("Failed to provision DNS zone: " + dnsZone.getName()); } return dnsZone; @@ -570,6 +569,10 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa networkId = nic != null ? nic.getNetworkId() : null; } + // networkId may not be of Shared network type + // there might be multiple shared networks + // possible to have dns record for secondary ip + if (nic == null) { throw new CloudRuntimeException("No valid NIC found for this Instance on the specified Network."); } @@ -592,7 +595,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa // Construct FQDN Prefix (e.g., "instance-id" or "instance-id.subdomain") String recordName = String.valueOf(instance.getInstanceName()); - if (map.getSubDomain() != null && !map.getSubDomain().isEmpty()) { + if (StringUtils.isNotBlank(map.getSubDomain() )) { recordName = recordName + "." + map.getSubDomain(); } @@ -664,6 +667,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa cmdList.add(DeleteDnsZoneCmd.class); cmdList.add(UpdateDnsZoneCmd.class); cmdList.add(AssociateDnsZoneToNetworkCmd.class); + cmdList.add(DisassociateDnsZoneFromNetworkCmd.class); // DNS Record Commands cmdList.add(CreateDnsRecordCmd.class); diff --git a/server/src/main/java/org/apache/cloudstack/dns/vo/DnsServerVO.java b/server/src/main/java/org/apache/cloudstack/dns/vo/DnsServerVO.java index a200cb98892..45fb81d64f4 100644 --- a/server/src/main/java/org/apache/cloudstack/dns/vo/DnsServerVO.java +++ b/server/src/main/java/org/apache/cloudstack/dns/vo/DnsServerVO.java @@ -17,6 +17,8 @@ package org.apache.cloudstack.dns.vo; +import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.UUID; @@ -34,7 +36,9 @@ import javax.persistence.TemporalType; import org.apache.cloudstack.dns.DnsProviderType; import org.apache.cloudstack.dns.DnsServer; +import org.apache.cloudstack.dns.DnsZone; +import com.cloud.utils.StringUtils; import com.cloud.utils.db.Encrypt; import com.cloud.utils.db.GenericDao; @@ -79,6 +83,9 @@ public class DnsServerVO implements DnsServer { @Column(name = "account_id") private long accountId; + @Column(name = "domain_id") + private long domainId; + @Column(name = "name_servers") private String nameServers; @@ -116,6 +123,11 @@ public class DnsServerVO implements DnsServer { return id; } + @Override + public Class getEntityType() { + return DnsZone.class; + } + @Override public String getName() { return name; @@ -206,7 +218,15 @@ public class DnsServerVO implements DnsServer { this.name = name; } - public String getNameServers() { - return nameServers; + public List getNameServers() { + if (StringUtils.isBlank(nameServers)) { + return Collections.emptyList(); + } + return Arrays.asList(nameServers.split(",")); + } + + @Override + public long getDomainId() { + return domainId; } }