From 9b77c3708e8b31af826067f4f8d7d6f109627cb3 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Mon, 16 Feb 2026 16:40:19 +0530 Subject: [PATCH] Tested following flow: 1. Add dns server 2. create zone 3. add records 4. verify in powerdns 5. verify using dig --- .../apache/cloudstack/api/ApiConstants.java | 2 + .../api/command/user/dns/AddDnsServerCmd.java | 11 +- .../command/user/dns/CreateDnsRecordCmd.java | 29 ++- .../command/user/dns/DeleteDnsRecordCmd.java | 17 +- .../command/user/dns/ListDnsRecordsCmd.java | 11 +- .../command/user/dns/UpdateDnsServerCmd.java | 17 ++ .../api/response/DnsRecordResponse.java | 36 +-- .../apache/cloudstack/dns/DnsProvider.java | 7 +- .../cloudstack/dns/DnsProviderManager.java | 3 +- .../org/apache/cloudstack/dns/DnsRecord.java | 14 +- .../org/apache/cloudstack/dns/DnsServer.java | 4 +- .../dns/powerdns/PowerDnsClient.java | 213 +++++++++++++++++- .../dns/powerdns/PowerDnsProvider.java | 57 ++++- .../dns/DnsProviderManagerImpl.java | 82 ++++++- .../apache/cloudstack/dns/vo/DnsServerVO.java | 9 +- 15 files changed, 429 insertions(+), 83 deletions(-) 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 a64e40fa7a2..5dbb200d32a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -1336,6 +1336,8 @@ public class ApiConstants { // DNS provider related 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 CONTENTS = "contents"; public static final String PUBLIC_DOMAIN_SUFFIX = "publicdomainsuffix"; public static final String PARAMETER_DESCRIPTION_ACTIVATION_RULE = "Quota tariff's activation rule. It can receive a JS script that results in either " + diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/AddDnsServerCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/AddDnsServerCmd.java index 764992d6e81..e5e38b7cbda 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/AddDnsServerCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/AddDnsServerCmd.java @@ -17,6 +17,8 @@ package org.apache.cloudstack.api.command.user.dns; +import java.util.List; + import javax.inject.Inject; import org.apache.cloudstack.api.APICommand; @@ -51,7 +53,7 @@ public class AddDnsServerCmd extends BaseCmd { @Parameter(name = ApiConstants.PROVIDER, type = CommandType.STRING, required = true, description = "Provider type (e.g., PowerDNS)") private String provider; - @Parameter(name = ApiConstants.CREDENTIALS, type = CommandType.STRING, required = false, description = "API Key or Credentials for the external provider") + @Parameter(name = ApiConstants.CREDENTIALS, type = CommandType.STRING, description = "API Key or Credentials for the external provider") private String credentials; @Parameter(name = ApiConstants.PORT, type = CommandType.INTEGER, description = "Port number of the external DNS server") @@ -63,8 +65,9 @@ public class AddDnsServerCmd extends BaseCmd { @Parameter(name = ApiConstants.PUBLIC_DOMAIN_SUFFIX, type = CommandType.STRING, description = "The domain suffix used for public access (e.g. public.example.com)") private String publicDomainSuffix; - @Parameter(name = ApiConstants.NAME_SERVERS, type = CommandType.STRING, description = "Comma separated list of name servers") - private String nameServers; + @Parameter(name = ApiConstants.NAME_SERVERS, type = CommandType.LIST, collectionType = CommandType.STRING, + required = true, description = "Comma separated list of name servers") + private List nameServers; ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// @@ -89,7 +92,7 @@ public class AddDnsServerCmd extends BaseCmd { return publicDomainSuffix; } - public String getNameServers() { + public List getNameServers() { return nameServers; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsRecordCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsRecordCmd.java index b041c46b72e..2967fabb9f0 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsRecordCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsRecordCmd.java @@ -1,5 +1,7 @@ package org.apache.cloudstack.api.command.user.dns; +import java.util.List; + import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; @@ -9,15 +11,19 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.DnsRecordResponse; import org.apache.cloudstack.api.response.DnsZoneResponse; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.dns.DnsRecord; import com.cloud.event.EventTypes; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.EnumUtils; @APICommand(name = "createDnsRecord", description = "Creates a DNS record directly on the provider", responseObject = DnsRecordResponse.class) public class CreateDnsRecordCmd extends BaseAsyncCmd { - @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, required = true) - private Long zoneId; + @Parameter(name = ApiConstants.DNS_ZONE_ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, required = true, + description = "ID of the DNS zone") + private Long dnsZoneId; @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, description = "Record name") private String name; @@ -25,19 +31,28 @@ public class CreateDnsRecordCmd extends BaseAsyncCmd { @Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, required = true, description = "Record type (A, CNAME)") private String type; - @Parameter(name = "content", type = CommandType.STRING, required = true, description = "IP or target") - private String content; + @Parameter(name = ApiConstants.CONTENTS, type = CommandType.LIST, collectionType = CommandType.STRING, required = true, + description = "The content of the record (IP address for A/AAAA, FQDN for CNAME/NS, quoted string for TXT, etc.)") + private List contents; @Parameter(name = "ttl", type = CommandType.INTEGER, description = "Time to live") private Integer ttl; // Getters - public Long getZoneId() { return zoneId; } + public Long getDnsZoneId() { return dnsZoneId; } public String getName() { return name; } - public String getType() { return type; } - public String getContent() { return content; } + + public List getContents() { return contents; } public Integer getTtl() { return (ttl == null) ? 3600 : ttl; } + public DnsRecord.RecordType getType() { + DnsRecord.RecordType dnsRecordType = EnumUtils.getEnumIgnoreCase(DnsRecord.RecordType.class, type); + if (dnsRecordType == null) { + throw new InvalidParameterValueException("Invalid value passed for record type, valid values are: " + EnumUtils.listValues(DnsRecord.RecordType.values())); + } + return dnsRecordType; + } + @Override public void execute() { try { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsRecordCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsRecordCmd.java index 3b0ea4a73ee..79e688db1ca 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsRecordCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsRecordCmd.java @@ -9,16 +9,19 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.DnsZoneResponse; import org.apache.cloudstack.api.response.SuccessResponse; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.dns.DnsRecord; import com.cloud.event.EventTypes; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.EnumUtils; @APICommand(name = "deleteDnsRecord", description = "Deletes a DNS record from the external provider", responseObject = SuccessResponse.class) public class DeleteDnsRecordCmd extends BaseAsyncCmd { - @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, + @Parameter(name = ApiConstants.DNS_ZONE_ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, required = true, description = "The ID of the DNS zone") - private Long zoneId; + private Long dnsZoneId; @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true) private String name; @@ -27,9 +30,15 @@ public class DeleteDnsRecordCmd extends BaseAsyncCmd { private String type; // Getters - public Long getZoneId() { return zoneId; } + public DnsRecord.RecordType getType() { + DnsRecord.RecordType dnsRecordType = EnumUtils.getEnumIgnoreCase(DnsRecord.RecordType.class, type); + if (dnsRecordType == null) { + throw new InvalidParameterValueException("Invalid value passed for record type, valid values are: " + EnumUtils.listValues(DnsRecord.RecordType.values())); + } + return dnsRecordType; + } + public Long getDnsZoneId() { return dnsZoneId; } public String getName() { return name; } - public String getType() { return type; } @Override public void execute() { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsRecordsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsRecordsCmd.java index a0c401250ed..c419ef98cc1 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsRecordsCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsRecordsCmd.java @@ -12,12 +12,13 @@ import org.apache.cloudstack.api.response.ListResponse; responseObject = DnsRecordResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) public class ListDnsRecordsCmd extends BaseListCmd { - @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, - required = true, description = "the ID of the DNS zone to list records from") - private Long zoneId; - public Long getZoneId() { - return zoneId; + @Parameter(name = ApiConstants.DNS_ZONE_ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, required = true, + description = "ID of the DNS zone to list records from") + private Long dnsZoneId; + + public Long getDnsZoneId() { + return dnsZoneId; } @Override diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsServerCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsServerCmd.java index c9f63b7f06a..051acc28600 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsServerCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsServerCmd.java @@ -30,6 +30,9 @@ import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.dns.DnsProviderManager; import org.apache.cloudstack.dns.DnsServer; import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.utils.EnumUtils; @APICommand(name = "updateDnsServer", description = "Update DNS server", responseObject = DnsServerResponse.class, requestHasSensitiveInfo = true) @@ -67,6 +70,9 @@ public class UpdateDnsServerCmd extends BaseCmd { @Parameter(name = ApiConstants.NAME_SERVERS, type = CommandType.STRING, description = "Comma separated list of name servers") private String nameServers; + @Parameter(name = ApiConstants.STATE, type = CommandType.STRING, description = "Update state for the DNS server (Enabled, Disabled)") + private String state; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -109,4 +115,15 @@ public class UpdateDnsServerCmd extends BaseCmd { throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, ex.getMessage()); } } + + public DnsServer.State getState() { + if (StringUtils.isBlank(state)) { + return null; + } + DnsServer.State dnsState = EnumUtils.getEnumIgnoreCase(DnsServer.State.class, state); + if (dnsState == null) { + throw new IllegalArgumentException("Invalid state value: " + state); + } + return dnsState; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/DnsRecordResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/DnsRecordResponse.java index 578a0f94072..6e208248551 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/DnsRecordResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/DnsRecordResponse.java @@ -17,6 +17,8 @@ package org.apache.cloudstack.api.response; +import java.util.List; + import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.BaseResponse; import org.apache.cloudstack.dns.DnsRecord; @@ -27,29 +29,21 @@ import com.google.gson.annotations.SerializedName; public class DnsRecordResponse extends BaseResponse { @SerializedName(ApiConstants.NAME) - @Param(description = "the name of the DNS record") + @Param(description = "The record name (e.g., www.example.com.)") private String name; @SerializedName(ApiConstants.TYPE) - @Param(description = "the type of the DNS record (A, CNAME, etc)") - private String type; + @Param(description = "The record type (e.g., A, CNAME, TXT)") + private DnsRecord.RecordType type; - @SerializedName("content") - @Param(description = "the content of the record (IP address or target)") - private String content; + @SerializedName("contents") + @Param(description = "The contents of the record (IP address or target)") + private List contents; @SerializedName("ttl") - @Param(description = "the time to live (TTL) in seconds") + @Param(description = "Time to live (TTL) in seconds") private Integer ttl; - @SerializedName(ApiConstants.ZONE_ID) - @Param(description = "the ID of the zone this record belongs to") - private String zoneId; - - @SerializedName("sourceid") - @Param(description = "the external ID of the record on the provider") - private String sourceId; - public DnsRecordResponse() { super(); setObjectName("dnsrecord"); @@ -57,15 +51,7 @@ public class DnsRecordResponse extends BaseResponse { // Setters public void setName(String name) { this.name = name; } - - // Accepts String or Enum.toString() - public void setType(String type) { this.type = type; } - public void setType(DnsRecord.RecordType type) { - this.type = (type != null) ? type.name() : null; - } - - public void setContent(String content) { this.content = content; } + public void setType(DnsRecord.RecordType type) { this.type = type; } + public void setContent(List contents) { this.contents = contents; } public void setTtl(Integer ttl) { this.ttl = ttl; } - public void setZoneId(String zoneId) { this.zoneId = zoneId; } - public void setSourceId(String sourceId) { this.sourceId = sourceId; } } 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 74c74ad8a5f..f03f391f46c 100644 --- a/api/src/main/java/org/apache/cloudstack/dns/DnsProvider.java +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsProvider.java @@ -31,9 +31,8 @@ public interface DnsProvider extends Adapter { void provisionZone(DnsServer server, DnsZone zone); void deleteZone(DnsServer server, DnsZone zone) ; - DnsRecord createRecord(DnsServer server, DnsZone zone, DnsRecord record); - boolean updateRecord(DnsServer server, DnsZone zone, DnsRecord record); - boolean deleteRecord(DnsServer server, DnsZone zone, DnsRecord record); - + 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); } 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 fc4bb004df6..b918b49fb39 100644 --- a/api/src/main/java/org/apache/cloudstack/dns/DnsProviderManager.java +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsProviderManager.java @@ -56,12 +56,10 @@ 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); - ListResponse listDnsRecords(ListDnsRecordsCmd cmd); List listProviderNames(); @@ -69,4 +67,5 @@ public interface DnsProviderManager extends Manager, PluggableService { // Helper to create the response object DnsZoneResponse createDnsZoneResponse(DnsZone zone); + DnsRecordResponse createDnsRecordResponse(DnsRecord record); } diff --git a/api/src/main/java/org/apache/cloudstack/dns/DnsRecord.java b/api/src/main/java/org/apache/cloudstack/dns/DnsRecord.java index 7ee6b51c3f2..ae62e4729cc 100644 --- a/api/src/main/java/org/apache/cloudstack/dns/DnsRecord.java +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsRecord.java @@ -17,6 +17,8 @@ package org.apache.cloudstack.dns; +import java.util.List; + import com.cloud.utils.exception.CloudRuntimeException; public class DnsRecord { @@ -36,16 +38,16 @@ public class DnsRecord { } private String name; - private RecordType type; // Enforced Enum here - private String content; + private RecordType type; + private List contents; private int ttl; public DnsRecord() {} - public DnsRecord(String name, RecordType type, String content, int ttl) { + public DnsRecord(String name, RecordType type, List contents, int ttl) { this.name = name; this.type = type; - this.content = content; + this.contents = contents; this.ttl = ttl; } @@ -56,8 +58,8 @@ public class DnsRecord { public RecordType getType() { return type; } public void setType(RecordType type) { this.type = type; } - public String getContent() { return content; } - public void setContent(String content) { this.content = content; } + public List getContents() { return contents; } + public void setContents(List contents) { this.contents = contents; } public int getTtl() { return ttl; } public void setTtl(int ttl) { this.ttl = ttl; } 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 dafd40017e5..617352cbfe3 100644 --- a/api/src/main/java/org/apache/cloudstack/dns/DnsServer.java +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsServer.java @@ -24,7 +24,7 @@ import org.apache.cloudstack.api.InternalIdentity; public interface DnsServer extends InternalIdentity, Identity { enum State { - Enabled, Disabled; + Enabled, Disabled }; String getName(); @@ -33,6 +33,8 @@ public interface DnsServer extends InternalIdentity, Identity { DnsProviderType getProviderType(); + String getNameServers(); + String getApiKey(); long getAccountId(); 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 244cef704b4..a69e2e7f165 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,13 +20,18 @@ 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 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.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; @@ -35,6 +40,7 @@ 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; @@ -89,8 +95,8 @@ public class PowerDnsClient implements AutoCloseable { } } - public void createZone(String baseUrl, String apiKey, String zoneName, List nameservers) { - String normalizedZone = zoneName.endsWith(".") ? zoneName : zoneName + "."; + public void 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(); @@ -98,13 +104,15 @@ public class PowerDnsClient implements AutoCloseable { json.put("kind", "Native"); json.put("dnssec", false); - if (nameservers != null && !nameservers.isEmpty()) { - ArrayNode nsArray = json.putArray("nameservers"); - for (String ns : nameservers) { - nsArray.add(ns.endsWith(".") ? ns : ns + "."); + 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"); @@ -114,9 +122,7 @@ public class PowerDnsClient implements AutoCloseable { try (CloseableHttpResponse response = httpClient.execute(request)) { int statusCode = response.getStatusLine().getStatusCode(); - String body = response.getEntity() != null - ? EntityUtils.toString(response.getEntity()) - : null; + String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : null; if (statusCode == HttpStatus.SC_CREATED) { logger.debug("Zone {} created successfully", zoneName); @@ -140,7 +146,7 @@ public class PowerDnsClient implements AutoCloseable { } public void deleteZone(String baseUrl, String apiKey, String zoneName) { - String normalizedZone = zoneName.endsWith(".") ? zoneName : zoneName + "."; + String normalizedZone = formatZoneName(zoneName); try { String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); String url = buildApiUrl(baseUrl, "/servers/localhost/zones/" + encodedZone); @@ -176,6 +182,155 @@ public class PowerDnsClient implements AutoCloseable { } } + 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() @@ -204,6 +359,42 @@ public class PowerDnsClient implements AutoCloseable { return normalizedUrl; } + private String formatZoneName(String zoneName) { + String zone = zoneName.trim().toLowerCase(); + if (!zone.endsWith(".")) { + zone += "."; + } + return zone; + } + + private String formatRecordName(String recordName, String zoneName) { + if (recordName == null) { + throw new IllegalArgumentException("Record name cannot be null"); + } + String normalizedZone = formatZoneName(zoneName); + String zoneWithoutDot = normalizedZone.substring(0, normalizedZone.length() - 1); + + String name = recordName.trim().toLowerCase(); + + // Root record + if (name.equals("@") || name.isEmpty()) { + return normalizedZone; + } + + // Already absolute + if (name.endsWith(".")) { + return name; + } + + // Fully qualified but missing trailing dot + if (name.equals(zoneWithoutDot) || name.endsWith("." + zoneWithoutDot)) { + return name + "."; + } + + // Relative name + return name + "." + normalizedZone; + } + private String buildApiUrl(String baseUrl, String path) { return normalizeBaseUrl(baseUrl) + "/api/v1" + path; } 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 3e9b5d5f83d..cb4740fff9d 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 @@ -17,6 +17,7 @@ package org.apache.cloudstack.dns.powerdns; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -28,6 +29,7 @@ import org.apache.cloudstack.dns.DnsZone; import com.cloud.utils.StringUtils; import com.cloud.utils.component.AdapterBase; +import com.fasterxml.jackson.databind.JsonNode; public class PowerDnsProvider extends AdapterBase implements DnsProvider { @@ -46,7 +48,7 @@ public class PowerDnsProvider extends AdapterBase implements DnsProvider { @Override public void provisionZone(DnsServer server, DnsZone zone) { validateServerZoneParams(server, zone); - client.createZone(server.getUrl(), server.getApiKey(), zone.getName(), null); + client.createZone(server.getUrl(), server.getApiKey(), zone.getName(), server.getNameServers()); } @Override @@ -55,24 +57,61 @@ public class PowerDnsProvider extends AdapterBase implements DnsProvider { client.deleteZone(server.getUrl(), server.getApiKey(), zone.getName()); } - @Override - public DnsRecord createRecord(DnsServer server, DnsZone zone, DnsRecord record) { - return null; + public enum ChangeType { + REPLACE, DELETE } @Override - public boolean updateRecord(DnsServer server, DnsZone zone, DnsRecord record) { - return false; + public void addRecord(DnsServer server, DnsZone zone, DnsRecord record) { + validateServerZoneParams(server, zone); + applyRecord(server.getUrl(), server.getApiKey(), zone.getName(), record, ChangeType.REPLACE); } @Override - public boolean deleteRecord(DnsServer server, DnsZone zone, DnsRecord record) { - return false; + public void updateRecord(DnsServer server, DnsZone zone, DnsRecord record) { + addRecord(server, zone, record); } + + @Override + public void deleteRecord(DnsServer server, DnsZone zone, DnsRecord record) { + 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(), + record.getTtl(), record.getContents(), changeType.name()); + } + + + @Override public List listRecords(DnsServer server, DnsZone zone) { - return List.of(); + List records = new ArrayList<>(); + for (JsonNode rrset: client.listRecords(server.getUrl(), server.getApiKey(), zone.getName())) { + String name = rrset.path("name").asText(); + String typeStr = rrset.path("type").asText(); + int ttl = rrset.path("ttl").asInt(0); + if (!"SOA".equalsIgnoreCase(typeStr)) { + try { + List contents = new ArrayList<>(); + JsonNode recordsNode = rrset.path("records"); + if (recordsNode.isArray()) { + for (JsonNode rec : recordsNode) { + String content = rec.path("content").asText(); + if (!content.isEmpty()) { + contents.add(content); + } + } + } + records.add(new DnsRecord(name, DnsRecord.RecordType.valueOf(typeStr), contents, ttl)); + } catch (Exception ignored) { + // Skip unsupported record types + } + } + } + return records; } void validateServerZoneParams(DnsServer server, DnsZone zone) { 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 6cc1b62e13d..c4f7c6155bf 100644 --- a/server/src/main/java/org/apache/cloudstack/dns/DnsProviderManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/dns/DnsProviderManagerImpl.java @@ -181,6 +181,10 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa if (cmd.getNameServers() != null) { dnsServer.setNameServers(cmd.getNameServers()); } + if (cmd.getState() != null) { + dnsServer.setState(cmd.getState()); + } + if (validationRequired) { DnsProvider provider = getProvider(dnsServer.getProviderType()); try { @@ -190,6 +194,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa throw new InvalidParameterValueException("Validation failed for DNS server"); } } + boolean updateStatus = dnsServerDao.update(dnsServerId, dnsServer); if (updateStatus) { @@ -308,17 +313,79 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa @Override public DnsRecordResponse createDnsRecord(CreateDnsRecordCmd cmd) { - return null; + DnsZoneVO zone = dnsZoneDao.findById(cmd.getDnsZoneId()); + if (zone == null) { + throw new InvalidParameterValueException("DNS Zone not found."); + } + + Account caller = CallContext.current().getCallingAccount(); + 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); + return createDnsRecordResponse(record); + } catch (Exception ex) { + logger.error("Failed to add DNS record via provider", ex); + throw new CloudRuntimeException(String.format("Failed to add DNS record: %s", cmd.getName())); + } } @Override public boolean deleteDnsRecord(DeleteDnsRecordCmd cmd) { - return false; + DnsZoneVO zone = dnsZoneDao.findById(cmd.getDnsZoneId()); + if (zone == null) { + throw new InvalidParameterValueException("DNS Zone not found."); + } + + Account caller = CallContext.current().getCallingAccount(); + accountMgr.checkAccess(caller, null, true, zone); + + DnsServerVO server = dnsServerDao.findById(zone.getDnsServerId()); + + try { + // Reconstruct the record DTO just for deletion criteria + DnsRecord record = new DnsRecord(); + record.setName(cmd.getName()); + record.setType(cmd.getType()); + DnsProvider provider = getProvider(server.getProviderType()); + provider.deleteRecord(server, zone, record); + return true; + } catch (Exception ex) { + logger.error("Failed to delete DNS record via provider", ex); + throw new CloudRuntimeException(String.format("Failed to delete record: %s", cmd.getName())); + } } @Override public ListResponse listDnsRecords(ListDnsRecordsCmd cmd) { - return null; + DnsZoneVO zone = dnsZoneDao.findById(cmd.getDnsZoneId()); + if (zone == null) { + throw new InvalidParameterValueException(String.format("DNS Zone with ID %s not found.", cmd.getDnsZoneId())); + } + Account caller = CallContext.current().getCallingAccount(); + accountMgr.checkAccess(caller, null, true, zone); + DnsServerVO server = dnsServerDao.findById(zone.getDnsServerId()); + if (server == null) { + throw new CloudRuntimeException("The underlying DNS Server for this zone is missing."); + } + try { + DnsProvider provider = getProvider(server.getProviderType()); + List records = provider.listRecords(server, zone); + List responses = new ArrayList<>(); + for (DnsRecord record : records) { + responses.add(createDnsRecordResponse(record)); + } + + ListResponse listResponse = new ListResponse<>(); + listResponse.setResponses(responses, responses.size()); + return listResponse; + } catch (Exception ex) { + logger.error("Failed to list DNS records from provider", ex); + throw new CloudRuntimeException("Failed to fetch DNS records"); + } } @Override @@ -393,6 +460,15 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa return res; } + @Override + public DnsRecordResponse createDnsRecordResponse(DnsRecord record) { + DnsRecordResponse res = new DnsRecordResponse(); + res.setName(record.getName()); + res.setType(record.getType()); + res.setContent(record.getContents()); + return res; + } + @Override public boolean start() { if (dnsProviders == null || dnsProviders.isEmpty()) { 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 4e216f8fac4..a200cb98892 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 @@ -18,6 +18,7 @@ package org.apache.cloudstack.dns.vo; import java.util.Date; +import java.util.List; import java.util.UUID; import javax.persistence.Column; @@ -95,7 +96,7 @@ public class DnsServerVO implements DnsServer { } public DnsServerVO(String name, String url, DnsProviderType providerType, String apiKey, - Integer port, boolean isPublic, String publicDomainSuffix, String nameServers, + Integer port, boolean isPublic, String publicDomainSuffix, List nameServers, long accountId) { this(); this.name = name; @@ -105,9 +106,9 @@ public class DnsServerVO implements DnsServer { this.apiKey = apiKey; this.accountId = accountId; this.publicDomainSuffix = publicDomainSuffix; - this.nameServers = nameServers; this.isPublic = isPublic; this.state = State.Enabled; + this.nameServers = String.join(",", nameServers);; } @Override @@ -204,4 +205,8 @@ public class DnsServerVO implements DnsServer { public void setName(String name) { this.name = name; } + + public String getNameServers() { + return nameServers; + } }