From f6aefd2664e4d17a47493f5fb4454d4d833da023 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Fri, 2 May 2025 12:31:00 -0400 Subject: [PATCH 01/23] add scaffolding for PowerDNS writer --- .../writer/powerdns/PowerDnsConfigModule.java | 24 + .../dns/writer/powerdns/PowerDnsWriter.java | 65 + .../writer/powerdns/PowerDnsWriterModule.java | 27 + .../powerdns/client/PowerDNSClient.java | 183 +++ .../writer/powerdns/client/model/Comment.java | 38 + .../writer/powerdns/client/model/RRSet.java | 77 + .../powerdns/client/model/RecordObject.java | 27 + .../writer/powerdns/client/model/Server.java | 82 + .../writer/powerdns/client/model/Zone.java | 267 ++++ .../openapi/authoritative-api-swagger.yaml | 1398 +++++++++++++++++ .../registry/module/RequestComponent.java | 4 + .../backend/BackendRequestComponent.java | 4 + .../registry/tools/RegistryToolComponent.java | 4 + 13 files changed, 2200 insertions(+) create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriterModule.java create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/openapi/authoritative-api-swagger.yaml diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java new file mode 100644 index 00000000000..130e31f7844 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java @@ -0,0 +1,24 @@ +package google.registry.dns.writer.powerdns; + +import dagger.Module; +import dagger.Provides; +import google.registry.config.RegistryConfig.Config; + +/** Dagger module that provides DNS configuration settings. */ +@Module +public class PowerDnsConfigModule { + + /** Host of the PowerDNS server. */ + @Provides + @Config("powerDnsHost") + public static String providePowerDnsHost() { + return "localhost"; + } + + /** API key for the PowerDNS server. */ + @Provides + @Config("powerDnsApiKey") + public static String providePowerDnsApiKey() { + return "dummy-api-key"; + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java new file mode 100644 index 00000000000..ba5dc725330 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -0,0 +1,65 @@ +package google.registry.dns.writer.powerdns; + +import com.google.common.flogger.FluentLogger; +import google.registry.config.RegistryConfig.Config; +import google.registry.dns.writer.BaseDnsWriter; +import google.registry.dns.writer.DnsWriterZone; +import google.registry.dns.writer.powerdns.client.PowerDNSClient; +import google.registry.util.Clock; +import jakarta.inject.Inject; +import java.util.HashSet; +import java.util.Set; + +/** A DnsWriter that implements the PowerDNS API. */ +public class PowerDnsWriter extends BaseDnsWriter { + public static final String NAME = "PowerDnsWriter"; + + private final Clock clock; + private final PowerDNSClient powerDnsClient; + private final String zoneName; + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private final Set names = new HashSet<>(); + + /** + * Class constructor. + * + * @param zoneName the name of the zone to write to + * @param powerDnsHost the host of the PowerDNS server + * @param powerDnsApiKey the API key for the PowerDNS server + * @param clock the clock to use for the PowerDNS writer + */ + @Inject + public PowerDnsWriter( + @DnsWriterZone String zoneName, + @Config("powerDnsHost") String powerDnsHost, + @Config("powerDnsApiKey") String powerDnsApiKey, + Clock clock) { + + // Initialize the PowerDNS client and zone name + this.powerDnsClient = new PowerDNSClient(powerDnsHost, powerDnsApiKey); + this.zoneName = zoneName; + this.clock = clock; + } + + @Override + public void publishDomain(String domainName) { + // TODO: Implement the logic to stage the domain zone files to PowerDNS for commit + names.add(domainName); + } + + @Override + public void publishHost(String hostName) { + // TODO: Implement the logic to stage the host glue records to PowerDNS for commit + names.add(hostName); + } + + @Override + protected void commitUnchecked() { + // TODO: Call the PowerDNS API to commit the changes + logger.atWarning().log( + "PowerDnsWriter for server ID %s not yet implemented; ignoring %s names to commit: %s at" + + " %s", + powerDnsClient.getServerId(), zoneName, names, clock.nowUtc()); + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriterModule.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriterModule.java new file mode 100644 index 00000000000..2c2de487828 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriterModule.java @@ -0,0 +1,27 @@ +package google.registry.dns.writer.powerdns; + +import dagger.Binds; +import dagger.Module; +import dagger.Provides; +import dagger.multibindings.IntoMap; +import dagger.multibindings.IntoSet; +import dagger.multibindings.StringKey; +import google.registry.dns.writer.DnsWriter; +import jakarta.inject.Named; + +/** Dagger module that provides a PowerDnsWriter. */ +@Module +public abstract class PowerDnsWriterModule { + + @Binds + @IntoMap + @StringKey(PowerDnsWriter.NAME) + abstract DnsWriter provideWriter(PowerDnsWriter writer); + + @Provides + @IntoSet + @Named("dnsWriterNames") + static String provideWriterName() { + return PowerDnsWriter.NAME; + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java new file mode 100644 index 00000000000..adad1e40bf8 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -0,0 +1,183 @@ +package google.registry.dns.writer.powerdns.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import google.registry.dns.writer.powerdns.client.model.Server; +import google.registry.dns.writer.powerdns.client.model.Zone; +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class PowerDNSClient { + // static fields + private final OkHttpClient httpClient; + private final ObjectMapper objectMapper; + private final String baseUrl; + private final String apiKey; + + // dynamic fields + private String serverId; + + public PowerDNSClient(String baseUrl, String apiKey) { + // initialize the base URL, API key, and HTTP client + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.httpClient = new OkHttpClient(); + this.objectMapper = new ObjectMapper(); + + // initialize the Server ID by querying the server list and choosing + // the first entry + try { + List servers = listServers(); + if (servers.isEmpty()) { + throw new IOException("No servers found"); + } + this.serverId = servers.get(0).getId(); + } catch (IOException e) { + this.serverId = "unknown-server-id"; + } + } + + public List listServers() throws IOException { + Request request = + new Request.Builder().url(baseUrl + "/servers").header("X-API-Key", apiKey).get().build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Failed to list servers: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), + objectMapper.getTypeFactory().constructCollectionType(List.class, Server.class)); + } + } + + public Server getServer() throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId) + .header("X-API-Key", apiKey) + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Failed to get server: " + response); + } + return objectMapper.readValue(Objects.requireNonNull(response.body()).string(), Server.class); + } + } + + public String getServerId() { + return serverId; + } + + public void setServerId(String serverId) { + this.serverId = serverId; + } + + public List listZones() throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones") + .header("X-API-Key", apiKey) + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Failed to list zones: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), + objectMapper.getTypeFactory().constructCollectionType(List.class, Zone.class)); + } + } + + public Zone getZone(String zoneId) throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId) + .header("X-API-Key", apiKey) + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Failed to get zone: " + response); + } + return objectMapper.readValue(Objects.requireNonNull(response.body()).string(), Zone.class); + } + } + + public Zone createZone(Zone zone) throws IOException { + String json = objectMapper.writeValueAsString(zone); + RequestBody body = RequestBody.create(json, MediaType.parse("application/json")); + + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones") + .header("X-API-Key", apiKey) + .post(body) + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Failed to create zone: " + response); + } + return objectMapper.readValue(Objects.requireNonNull(response.body()).string(), Zone.class); + } + } + + public void deleteZone(String zoneId) throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId) + .header("X-API-Key", apiKey) + .delete() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Failed to delete zone: " + response); + } + } + } + + public void patchZone(String zoneId, Zone zone) throws IOException { + String json = objectMapper.writeValueAsString(zone); + RequestBody body = RequestBody.create(json, MediaType.parse("application/json")); + + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId) + .header("X-API-Key", apiKey) + .patch(body) + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Failed to patch zone: " + response); + } + } + } + + public void notifyZone(String zoneId) throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId + "/notify") + .header("X-API-Key", apiKey) + .put(RequestBody.create("", MediaType.parse("application/json"))) + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Failed to notify zone: " + response); + } + } + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java new file mode 100644 index 00000000000..32d9cb87db3 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java @@ -0,0 +1,38 @@ +package google.registry.dns.writer.powerdns.client.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class Comment { + @JsonProperty("content") + private String content; + + @JsonProperty("account") + private String account; + + @JsonProperty("modified_at") + private Long modifiedAt; + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public Long getModifiedAt() { + return modifiedAt; + } + + public void setModifiedAt(Long modifiedAt) { + this.modifiedAt = modifiedAt; + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java new file mode 100644 index 00000000000..42ea5ad735a --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java @@ -0,0 +1,77 @@ +package google.registry.dns.writer.powerdns.client.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public class RRSet { + @JsonProperty("name") + private String name; + + @JsonProperty("type") + private String type; + + @JsonProperty("ttl") + private Integer ttl; + + @JsonProperty("changetype") + private ChangeType changetype; + + @JsonProperty("records") + private List records; + + @JsonProperty("comments") + private List comments; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Integer getTtl() { + return ttl; + } + + public void setTtl(Integer ttl) { + this.ttl = ttl; + } + + public ChangeType getChangetype() { + return changetype; + } + + public void setChangetype(ChangeType changetype) { + this.changetype = changetype; + } + + public List getRecords() { + return records; + } + + public void setRecords(List records) { + this.records = records; + } + + public List getComments() { + return comments; + } + + public void setComments(List comments) { + this.comments = comments; + } + + public enum ChangeType { + REPLACE, + DELETE + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java new file mode 100644 index 00000000000..ffe3c2e40cf --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java @@ -0,0 +1,27 @@ +package google.registry.dns.writer.powerdns.client.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RecordObject { + @JsonProperty("content") + private String content; + + @JsonProperty("disabled") + private Boolean disabled; + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Boolean getDisabled() { + return disabled; + } + + public void setDisabled(Boolean disabled) { + this.disabled = disabled; + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java new file mode 100644 index 00000000000..135f488a4ae --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java @@ -0,0 +1,82 @@ +package google.registry.dns.writer.powerdns.client.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class Server { + @JsonProperty("type") + private String type; + + @JsonProperty("id") + private String id; + + @JsonProperty("daemon_type") + private String daemonType; + + @JsonProperty("version") + private String version; + + @JsonProperty("url") + private String url; + + @JsonProperty("config_url") + private String configUrl; + + @JsonProperty("zones_url") + private String zonesUrl; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDaemonType() { + return daemonType; + } + + public void setDaemonType(String daemonType) { + this.daemonType = daemonType; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getConfigUrl() { + return configUrl; + } + + public void setConfigUrl(String configUrl) { + this.configUrl = configUrl; + } + + public String getZonesUrl() { + return zonesUrl; + } + + public void setZonesUrl(String zonesUrl) { + this.zonesUrl = zonesUrl; + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java new file mode 100644 index 00000000000..08a96c18fe4 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java @@ -0,0 +1,267 @@ +package google.registry.dns.writer.powerdns.client.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public class Zone { + @JsonProperty("id") + private String id; + + @JsonProperty("name") + private String name; + + @JsonProperty("type") + private String type; + + @JsonProperty("url") + private String url; + + @JsonProperty("kind") + private ZoneKind kind; + + @JsonProperty("rrsets") + private List rrsets; + + @JsonProperty("serial") + private Integer serial; + + @JsonProperty("notified_serial") + private Integer notifiedSerial; + + @JsonProperty("edited_serial") + private Integer editedSerial; + + @JsonProperty("masters") + private List masters; + + @JsonProperty("dnssec") + private Boolean dnssec; + + @JsonProperty("nsec3param") + private String nsec3param; + + @JsonProperty("nsec3narrow") + private Boolean nsec3narrow; + + @JsonProperty("presigned") + private Boolean presigned; + + @JsonProperty("soa_edit") + private String soaEdit; + + @JsonProperty("soa_edit_api") + private String soaEditApi; + + @JsonProperty("api_rectify") + private Boolean apiRectify; + + @JsonProperty("zone") + private String zone; + + @JsonProperty("catalog") + private String catalog; + + @JsonProperty("account") + private String account; + + @JsonProperty("nameservers") + private List nameservers; + + @JsonProperty("master_tsig_key_ids") + private List masterTsigKeyIds; + + @JsonProperty("slave_tsig_key_ids") + private List slaveTsigKeyIds; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public ZoneKind getKind() { + return kind; + } + + public void setKind(ZoneKind kind) { + this.kind = kind; + } + + public List getRrsets() { + return rrsets; + } + + public void setRrsets(List rrsets) { + this.rrsets = rrsets; + } + + public Integer getSerial() { + return serial; + } + + public void setSerial(Integer serial) { + this.serial = serial; + } + + public Integer getNotifiedSerial() { + return notifiedSerial; + } + + public void setNotifiedSerial(Integer notifiedSerial) { + this.notifiedSerial = notifiedSerial; + } + + public Integer getEditedSerial() { + return editedSerial; + } + + public void setEditedSerial(Integer editedSerial) { + this.editedSerial = editedSerial; + } + + public List getMasters() { + return masters; + } + + public void setMasters(List masters) { + this.masters = masters; + } + + public Boolean getDnssec() { + return dnssec; + } + + public void setDnssec(Boolean dnssec) { + this.dnssec = dnssec; + } + + public String getNsec3param() { + return nsec3param; + } + + public void setNsec3param(String nsec3param) { + this.nsec3param = nsec3param; + } + + public Boolean getNsec3narrow() { + return nsec3narrow; + } + + public void setNsec3narrow(Boolean nsec3narrow) { + this.nsec3narrow = nsec3narrow; + } + + public Boolean getPresigned() { + return presigned; + } + + public void setPresigned(Boolean presigned) { + this.presigned = presigned; + } + + public String getSoaEdit() { + return soaEdit; + } + + public void setSoaEdit(String soaEdit) { + this.soaEdit = soaEdit; + } + + public String getSoaEditApi() { + return soaEditApi; + } + + public void setSoaEditApi(String soaEditApi) { + this.soaEditApi = soaEditApi; + } + + public Boolean getApiRectify() { + return apiRectify; + } + + public void setApiRectify(Boolean apiRectify) { + this.apiRectify = apiRectify; + } + + public String getZone() { + return zone; + } + + public void setZone(String zone) { + this.zone = zone; + } + + public String getCatalog() { + return catalog; + } + + public void setCatalog(String catalog) { + this.catalog = catalog; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public List getNameservers() { + return nameservers; + } + + public void setNameservers(List nameservers) { + this.nameservers = nameservers; + } + + public List getMasterTsigKeyIds() { + return masterTsigKeyIds; + } + + public void setMasterTsigKeyIds(List masterTsigKeyIds) { + this.masterTsigKeyIds = masterTsigKeyIds; + } + + public List getSlaveTsigKeyIds() { + return slaveTsigKeyIds; + } + + public void setSlaveTsigKeyIds(List slaveTsigKeyIds) { + this.slaveTsigKeyIds = slaveTsigKeyIds; + } + + public enum ZoneKind { + Native, + Master, + Slave, + Producer, + Consumer + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/openapi/authoritative-api-swagger.yaml b/core/src/main/java/google/registry/dns/writer/powerdns/client/openapi/authoritative-api-swagger.yaml new file mode 100644 index 00000000000..555fe70c2b7 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/openapi/authoritative-api-swagger.yaml @@ -0,0 +1,1398 @@ +swagger: '2.0' +info: + version: "0.0.15" + title: PowerDNS Authoritative HTTP API + license: + name: MIT +basePath: /api/v1 +consumes: + - application/json +produces: + - application/json +securityDefinitions: + # X-API-Key: abcdef12345 + APIKeyHeader: + type: apiKey + in: header + name: X-API-Key +security: + - APIKeyHeader: [] + +# Overall TODOS: +# TODO: Return types are not consistent across documentation +# We need to look at the code and figure out the default HTTP response +# codes and adjust docs accordingly. +paths: + '/error': + get: + summary: Will always generate an error + operationId: error + responses: &commonErrors + '400': + description: The supplied request was not valid + schema: + $ref: '#/definitions/Error' + '404': + description: Requested item was not found + schema: + $ref: '#/definitions/Error' + '422': + description: The input to the operation was not valid + schema: + $ref: '#/definitions/Error' + '500': + description: Internal server error + schema: + $ref: '#/definitions/Error' + + '/servers': + get: + summary: List all servers + operationId: listServers + tags: + - servers + responses: + '200': + description: An array of servers + schema: + type: array + items: + $ref: '#/definitions/Server' + <<: *commonErrors + + '/servers/{server_id}': + get: + summary: List a server + operationId: listServer + tags: + - servers + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + responses: + '200': + description: An server + schema: + $ref: '#/definitions/Server' + <<: *commonErrors + + '/servers/{server_id}/cache/flush': + put: + summary: Flush a cache-entry by name + operationId: cacheFlushByName + tags: + - servers + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: domain + in: query + required: true + description: The domain name to flush from the cache + type: string + responses: + '200': + description: Flush successful + schema: + $ref: '#/definitions/CacheFlushResult' + <<: *commonErrors + + '/servers/{server_id}/zones': + get: + summary: List all Zones in a server + operationId: listZones + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone + in: query + required: false + type: string + description: | + When set to the name of a zone, only this zone is returned. + If no zone with that name exists, the response is an empty array. + This can e.g. be used to check if a zone exists in the database without having to guess/encode the zone's id or to check if a zone exists. + - name: dnssec + in: query + required: false + type: boolean + default: true + description: '“true” (default) or “false”, whether to include the “dnssec” and ”edited_serial” fields in the Zone objects. Setting this to ”false” will make the query a lot faster.' + responses: + '200': + description: An array of Zones + schema: + type: array + items: + $ref: '#/definitions/Zone' + <<: *commonErrors + post: + summary: Creates a new domain, returns the Zone on creation. + operationId: createZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: rrsets + in: query + description: '“true” (default) or “false”, whether to include the “rrsets” in the response Zone object.' + type: boolean + default: true + - name: zone_struct + description: The zone struct to patch with + required: true + in: body + schema: + $ref: '#/definitions/Zone' + responses: + '201': + description: A zone + schema: + $ref: '#/definitions/Zone' + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}': + get: + summary: zone managed by a server + operationId: listZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + - name: rrsets + in: query + description: '“true” (default) or “false”, whether to include the “rrsets” in the response Zone object.' + type: boolean + default: true + - name: rrset_name + in: query + description: Limit output to RRsets for this name. + type: string + - name: rrset_type + in: query + description: Limit output to the RRset of this type. Can only be used together with rrset_name. + type: string + - name: include_disabled + in: query + description: '“true” (default) or “false”, whether to include disabled RRsets in the response.' + type: boolean + responses: + '200': + description: A Zone + schema: + $ref: '#/definitions/Zone' + <<: *commonErrors + delete: + summary: Deletes this zone, all attached metadata and rrsets. + operationId: deleteZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '204': + description: 'Returns 204 No Content on success.' + <<: *commonErrors + patch: + summary: 'Creates/modifies/deletes RRsets present in the payload and their comments. Returns 204 No Content on success.' + operationId: patchZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: zone_struct + description: The zone struct to patch with + required: true + in: body + schema: + $ref: '#/definitions/Zone' + responses: + '204': + description: 'Returns 204 No Content on success.' + <<: *commonErrors + + put: + summary: Modifies basic zone data. + description: 'The only fields in the zone structure which can be modified are: kind, masters, catalog, account, soa_edit, soa_edit_api, api_rectify, dnssec, and nsec3param. All other fields are ignored.' + operationId: putZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: zone_struct + description: The zone struct to patch with + required: true + in: body + schema: + $ref: '#/definitions/Zone' + responses: + '204': + description: 'Returns 204 No Content on success.' + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/notify': + put: + summary: Send a DNS NOTIFY to all slaves. + description: 'Fails when zone kind is not Master or Slave, or master and slave are disabled in the configuration. Only works for Slave if renotify is on. Clients MUST NOT send a body.' + operationId: notifyZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: OK + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/axfr-retrieve': + put: + summary: Retrieve slave zone from its master. + description: 'Fails when zone kind is not Slave, or slave is disabled in the configuration. Clients MUST NOT send a body.' + operationId: axfrRetrieveZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: OK + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/export': + get: + summary: 'Returns the zone in AXFR format.' + operationId: axfrExportZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: OK + schema: + type: string + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/rectify': + put: + summary: 'Rectify the zone data.' + description: 'This does not take into account the API-RECTIFY metadata. Fails on slave zones and zones that do not have DNSSEC.' + operationId: rectifyZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: OK + schema: + type: string + <<: *commonErrors + + '/servers/{server_id}/config': + get: + summary: 'Returns all ConfigSettings for a single server' + operationId: getConfig + tags: + - config + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + responses: + '200': + description: List of config values + schema: + type: array + items: + $ref: '#/definitions/ConfigSetting' + <<: *commonErrors + + '/servers/{server_id}/config/{config_setting_name}': + get: + summary: 'Returns a specific ConfigSetting for a single server' + description: 'NOT IMPLEMENTED' + operationId: getConfigSetting + tags: + - config + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: config_setting_name + in: path + required: true + description: The name of the setting to retrieve + type: string + responses: + '200': + description: List of config values + schema: + $ref: '#/definitions/ConfigSetting' + <<: *commonErrors + + '/servers/{server_id}/statistics': + get: + summary: 'Query statistics.' + description: 'Query PowerDNS internal statistics.' + operationId: getStats + tags: + - stats + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: statistic + in: query + required: false + type: string + description: | + When set to the name of a specific statistic, only this value is returned. + If no statistic with that name exists, the response has a 422 status and an error message. + - name: includerings + in: query + required: false + type: boolean + default: true + description: '“true” (default) or “false”, whether to include the Ring items, which can contain thousands of log messages or queried domains. Setting this to ”false” may make the response a lot smaller.' + responses: + '200': + description: List of Statistic Items + schema: + type: array + items: + - $ref: '#/definitions/StatisticItem' + - $ref: '#/definitions/MapStatisticItem' + - $ref: '#/definitions/RingStatisticItem' + '422': + description: 'Returned when a non-existing statistic name has been requested. Contains an error message' + <<: *commonErrors + + '/servers/{server_id}/search-data': + get: + summary: 'Search the data inside PowerDNS' + description: 'Search the data inside PowerDNS for search_term and return at most max_results. This includes zones, records and comments. The * character can be used in search_term as a wildcard character and the ? character can be used as a wildcard for a single character.' + operationId: searchData + tags: + - search + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: q + in: query + required: true + description: 'The string to search for' + type: string + - name: max + in: query + required: true + description: 'Maximum number of entries to return' + type: integer + - name: object_type + in: query + required: false + description: 'Type of data to search for, one of “all”, “zone”, “record”, “comment”' + type: string + responses: + '200': + description: Returns a JSON array with results + schema: + $ref: '#/definitions/SearchResults' + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/metadata': + get: + summary: 'Get all the Metadata associated with the zone.' + operationId: listMetadata + tags: + - zonemetadata + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: List of Metadata objects + schema: + type: array + items: + $ref: '#/definitions/Metadata' + <<: *commonErrors + post: + summary: 'Creates a set of metadata entries' + description: 'Creates a set of metadata entries of given kind for the zone. Existing metadata entries for the zone with the same kind are not overwritten.' + operationId: createMetadata + tags: + - zonemetadata + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: metadata + description: Metadata object with list of values to create + required: true + in: body + schema: + $ref: '#/definitions/Metadata' + responses: + '204': + description: OK + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/metadata/{metadata_kind}': + get: + summary: 'Get the content of a single kind of domain metadata as a Metadata object.' + operationId: getMetadata + tags: + - zonemetadata + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + - name: metadata_kind + type: string + in: path + required: true + description: The kind of metadata + responses: + '200': + description: Metadata object with list of values + schema: + $ref: '#/definitions/Metadata' + <<: *commonErrors + put: + summary: 'Replace the content of a single kind of domain metadata.' + description: 'Creates a set of metadata entries of given kind for the zone. Existing metadata entries for the zone with the same kind are removed.' + operationId: modifyMetadata + tags: + - zonemetadata + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: metadata_kind + description: The kind of metadata + required: true + type: string + in: path + - name: metadata + description: metadata to add/create + required: true + in: body + schema: + $ref: '#/definitions/Metadata' + responses: + '200': + description: Metadata object with list of values + schema: + $ref: '#/definitions/Metadata' + <<: *commonErrors + delete: + summary: 'Delete all items of a single kind of domain metadata.' + operationId: deleteMetadata + tags: + - zonemetadata + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + - name: metadata_kind + type: string + in: path + required: true + description: The kind of metadata + responses: + '204': + description: OK + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/cryptokeys': + get: + summary: 'Get all CryptoKeys for a zone, except the privatekey' + operationId: listCryptokeys + tags: + - zonecryptokey + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: List of Cryptokey objects + schema: + type: array + items: + $ref: '#/definitions/Cryptokey' + <<: *commonErrors + post: + summary: 'Creates a Cryptokey' + description: 'This method adds a new key to a zone. The key can either be generated or imported by supplying the content parameter. if content, bits and algo are null, a key will be generated based on the default-ksk-algorithm and default-ksk-size settings for a KSK and the default-zsk-algorithm and default-zsk-size options for a ZSK.' + operationId: createCryptokey + tags: + - zonecryptokey + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: cryptokey + description: Add a Cryptokey + required: true + in: body + schema: + $ref: '#/definitions/Cryptokey' + responses: + '201': + description: Created + schema: + $ref: '#/definitions/Cryptokey' + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/cryptokeys/{cryptokey_id}': + get: + summary: 'Returns all data about the CryptoKey, including the privatekey.' + operationId: getCryptokey + tags: + - zonecryptokey + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + - name: cryptokey_id + type: string + in: path + required: true + description: 'The id value of the CryptoKey' + responses: + '200': + description: Cryptokey + schema: + $ref: '#/definitions/Cryptokey' + <<: *commonErrors + put: + summary: 'This method (de)activates a key from zone_name specified by cryptokey_id' + operationId: modifyCryptokey + tags: + - zonecryptokey + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: cryptokey_id + description: Cryptokey to manipulate + required: true + in: path + type: string + - name: cryptokey + description: the Cryptokey + required: true + in: body + schema: + $ref: '#/definitions/Cryptokey' + responses: + '204': + description: OK + <<: *commonErrors + delete: + summary: 'This method deletes a key specified by cryptokey_id.' + operationId: deleteCryptokey + tags: + - zonecryptokey + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + - name: cryptokey_id + type: string + in: path + required: true + description: 'The id value of the Cryptokey' + responses: + '204': + description: OK + <<: *commonErrors + + '/servers/{server_id}/tsigkeys': + parameters: + - name: server_id + in: path + required: true + description: 'The id of the server' + type: string + get: + summary: 'Get all TSIGKeys on the server, except the actual key' + operationId: listTSIGKeys + tags: + - tsigkey + responses: + '200': + description: List of TSIGKey objects + schema: + type: array + items: + $ref: '#/definitions/TSIGKey' + <<: *commonErrors + post: + summary: 'Add a TSIG key' + description: 'This methods add a new TSIGKey. The actual key can be generated by the server or be provided by the client' + operationId: createTSIGKey + tags: + - tsigkey + parameters: + - name: tsigkey + description: The TSIGKey to add + required: true + in: body + schema: + $ref: '#/definitions/TSIGKey' + responses: + '201': + description: Created + schema: + $ref: '#/definitions/TSIGKey' + '409': + description: An item with this name already exists + schema: + $ref: '#/definitions/Error' + <<: *commonErrors + + '/servers/{server_id}/tsigkeys/{tsigkey_id}': + parameters: + - name: server_id + in: path + required: true + description: 'The id of the server to retrieve the key from' + type: string + - name: tsigkey_id + in: path + required: true + description: 'The id of the TSIGkey. Should match the "id" field in the TSIGKey object' + type: string + get: + summary: 'Get a specific TSIGKeys on the server, including the actual key' + operationId: getTSIGKey + tags: + - tsigkey + responses: + '200': + description: OK. + schema: + $ref: '#/definitions/TSIGKey' + <<: *commonErrors + put: + description: | + The TSIGKey at tsigkey_id can be changed in multiple ways: + * Changing the Name, this will remove the key with tsigkey_id after adding. + * Changing the Algorithm + * Changing the Key + + Only the relevant fields have to be provided in the request body. + operationId: putTSIGKey + tags: + - tsigkey + parameters: + - name: tsigkey + description: A (possibly stripped down) TSIGKey object with the new values + schema: + $ref: '#/definitions/TSIGKey' + in: body + required: true + responses: + '200': + description: OK. TSIGKey is changed. + schema: + $ref: '#/definitions/TSIGKey' + '409': + description: An item with this name already exists + schema: + $ref: '#/definitions/Error' + <<: *commonErrors + delete: + summary: 'Delete the TSIGKey with tsigkey_id' + operationId: deleteTSIGKey + tags: + - tsigkey + responses: + '204': + description: 'OK, key was deleted' + <<: *commonErrors + + '/servers/{server_id}/autoprimaries': + parameters: + - name: server_id + in: path + required: true + description: 'The id of the server to manage the list of autoprimaries on' + type: string + get: + summary: 'Get a list of autoprimaries' + operationId: getAutoprimaries + tags: + - autoprimary + responses: + '200': + description: OK. + schema: + $ref: '#/definitions/Autoprimary' + <<: *commonErrors + post: + summary: 'Add an autoprimary' + description: 'This methods add a new autoprimary server.' + operationId: createAutoprimary + tags: + - autoprimary + parameters: + - name: autoprimary + description: autoprimary entry to add + required: true + in: body + schema: + $ref: '#/definitions/Autoprimary' + responses: + '201': + description: Created + <<: *commonErrors + + '/servers/{server_id}/autoprimaries/{ip}/{nameserver}': + parameters: + - name: server_id + in: path + required: true + description: 'The id of the server to delete the autoprimary from' + type: string + - name: ip + in: path + required: true + description: 'IP address of autoprimary' + type: string + - name: nameserver + in: path + required: true + description: 'DNS name of the autoprimary' + type: string + delete: + summary: 'Delete the autoprimary entry' + operationId: deleteAutoprimary + tags: + - autoprimary + responses: + '204': + description: 'OK, key was deleted' + <<: *commonErrors + +definitions: + Server: + title: Server + properties: + type: + type: string + description: 'Set to “Server”' + id: + type: string + description: 'The id of the server, “localhost”' + daemon_type: + type: string + description: '“recursor” for the PowerDNS Recursor and “authoritative” for the Authoritative Server' + version: + type: string + description: 'The version of the server software' + url: + type: string + description: 'The API endpoint for this server' + config_url: + type: string + description: 'The API endpoint for this server’s configuration' + zones_url: + type: string + description: 'The API endpoint for this server’s zones' + + Servers: + type: array + items: + $ref: '#/definitions/Server' + + Zone: + title: Zone + description: This represents an authoritative DNS Zone. + properties: + id: + type: string + description: 'Opaque zone id (string), assigned by the server, should not be interpreted by the application. Guaranteed to be safe for embedding in URLs.' + name: + type: string + description: 'Name of the zone (e.g. “example.com.”) MUST have a trailing dot' + type: + type: string + description: 'Set to “Zone”' + url: + type: string + description: 'API endpoint for this zone' + kind: + type: string + enum: + - 'Native' + - 'Master' + - 'Slave' + - 'Producer' + - 'Consumer' + description: 'Zone kind, one of “Native”, “Master”, “Slave”, “Producer”, “Consumer”' + rrsets: + type: array + items: + $ref: '#/definitions/RRSet' + description: 'RRSets in this zone (for zones/{zone_id} endpoint only; omitted during GET on the .../zones list endpoint)' + serial: + type: integer + description: 'The SOA serial number' + notified_serial: + type: integer + description: 'The SOA serial notifications have been sent out for' + edited_serial: + type: integer + description: 'The SOA serial as seen in query responses. Calculated using the SOA-EDIT metadata, default-soa-edit and default-soa-edit-signed settings' + masters: + type: array + items: + type: string + description: ' List of IP addresses configured as a master for this zone (“Slave” type zones only)' + dnssec: + type: boolean + description: 'Whether or not this zone is DNSSEC signed (inferred from presigned being true XOR presence of at least one cryptokey with active being true)' + nsec3param: + type: string + description: 'The NSEC3PARAM record' + nsec3narrow: + type: boolean + description: 'Whether or not the zone uses NSEC3 narrow' + presigned: + type: boolean + description: 'Whether or not the zone is pre-signed' + soa_edit: + type: string + description: 'The SOA-EDIT metadata item' + soa_edit_api: + type: string + description: 'The SOA-EDIT-API metadata item' + api_rectify: + type: boolean + description: 'Whether or not the zone will be rectified on data changes via the API' + zone: + type: string + description: 'MAY contain a BIND-style zone file when creating a zone' + catalog: + type: string + description: 'The catalog this zone is a member of' + account: + type: string + description: 'MAY be set. Its value is defined by local policy' + nameservers: + type: array + items: + type: string + description: 'MAY be sent in client bodies during creation, and MUST NOT be sent by the server. Simple list of strings of nameserver names, including the trailing dot. Not required for slave zones.' + master_tsig_key_ids: + type: array + items: + type: string + description: 'The id of the TSIG keys used for master operation in this zone' + externalDocs: + url: 'https://doc.powerdns.com/authoritative/tsig.html#provisioning-outbound-axfr-access' + slave_tsig_key_ids: + type: array + items: + type: string + description: 'The id of the TSIG keys used for slave operation in this zone' + externalDocs: + url: 'https://doc.powerdns.com/authoritative/tsig.html#provisioning-signed-notification-and-axfr-requests' + + Zones: + type: array + items: + $ref: '#/definitions/Zone' + + RRSet: + title: RRSet + description: This represents a Resource Record Set (all records with the same name and type). + required: + - name + - type + - ttl + - changetype + - records + properties: + name: + type: string + description: 'Name for record set (e.g. “www.powerdns.com.”)' + type: + type: string + description: 'Type of this record (e.g. “A”, “PTR”, “MX”)' + ttl: + type: integer + description: 'DNS TTL of the records, in seconds. MUST NOT be included when changetype is set to “DELETE”.' + changetype: + type: string + description: 'MUST be added when updating the RRSet. Must be REPLACE or DELETE. With DELETE, all existing RRs matching name and type will be deleted, including all comments. With REPLACE: when records is present, all existing RRs matching name and type will be deleted, and then new records given in records will be created. If no records are left, any existing comments will be deleted as well. When comments is present, all existing comments for the RRs matching name and type will be deleted, and then new comments given in comments will be created.' + records: + type: array + description: 'All records in this RRSet. When updating Records, this is the list of new records (replacing the old ones). Must be empty when changetype is set to DELETE. An empty list results in deletion of all records (and comments).' + items: + $ref: '#/definitions/Record' + comments: + type: array + description: 'List of Comment. Must be empty when changetype is set to DELETE. An empty list results in deletion of all comments. modified_at is optional and defaults to the current server time.' + items: + $ref: '#/definitions/Comment' + + Record: + title: Record + description: The RREntry object represents a single record. + required: + - content + properties: + content: + type: string + description: 'The content of this record' + disabled: + type: boolean + description: 'Whether or not this record is disabled. When unset, the record is not disabled' + + Comment: + title: Comment + description: A comment about an RRSet. + properties: + content: + type: string + description: 'The actual comment' + account: + type: string + description: 'Name of an account that added the comment' + modified_at: + type: integer + description: 'Timestamp of the last change to the comment' + + TSIGKey: + title: TSIGKey + description: A TSIG key that can be used to authenticate NOTIFY, AXFR, and DNSUPDATE queries. + properties: + name: + type: string + description: 'The name of the key' + id: + type: string + description: 'The ID for this key, used in the TSIGkey URL endpoint.' + readOnly: true + algorithm: + type: string + description: 'The algorithm of the TSIG key' + key: + type: string + description: 'The Base64 encoded secret key, empty when listing keys. MAY be empty when POSTing to have the server generate the key material' + type: + type: string + description: 'Set to "TSIGKey"' + readOnly: true + + Autoprimary: + title: Autoprimary server + description: An autoprimary server that can provision new domains. + properties: + ip: + type: string + description: "IP address of the autoprimary server" + nameserver: + type: string + description: "DNS name of the autoprimary server" + account: + type: string + description: "Account name for the autoprimary server" + + ConfigSetting: + title: ConfigSetting + properties: + name: + type: string + description: 'set to "ConfigSetting"' + type: + type: string + description: 'The name of this setting (e.g. ‘webserver-port’)' + value: + type: string + description: 'The value of setting name' + + SimpleStatisticItem: + title: SimpleStatisticItem + type: object + properties: + name: + type: string + description: 'Item name' + value: + type: string + description: 'Item value' + + StatisticItem: + title: StatisticItem + properties: + name: + type: string + description: 'Item name' + type: + type: string + description: 'set to "StatisticItem"' + value: + type: string + description: 'Item value' + + MapStatisticItem: + title: MapStatisticItem + properties: + name: + type: string + description: 'Item name' + type: + type: string + description: 'Set to "MapStatisticItem"' + value: + type: array + description: 'Named values' + items: + $ref: '#/definitions/SimpleStatisticItem' + + RingStatisticItem: + title: RingStatisticItem + properties: + name: + type: string + description: 'Item name' + type: + type: string + description: 'Set to "RingStatisticItem"' + size: + type: integer + description: 'Ring size' + value: + type: array + description: 'Named values' + items: + $ref: '#/definitions/SimpleStatisticItem' + + SearchResultZone: + title: SearchResultZone + properties: + name: + type: string + object_type: + type: string + description: 'set to "zone"' + zone_id: + type: string + + SearchResultRecord: + title: SearchResultRecord + properties: + content: + type: string + disabled: + type: boolean + name: + type: string + object_type: + type: string + description: 'set to "record"' + zone_id: + type: string + zone: + type: string + type: + type: string + ttl: + type: integer + + SearchResultComment: + title: SearchResultComment + properties: + content: + type: string + name: + type: string + object_type: + type: string + description: 'set to "comment"' + zone_id: + type: string + zone: + type: string + +# FIXME: This is problematic at the moment, because swagger doesn't support this type of mixed response +# SearchResult: +# anyOf: +# - $ref: '#/definitions/SearchResultZone' +# - $ref: '#/definitions/SearchResultRecord' +# - $ref: '#/definitions/SearchResultComment' + +# Since we can't do 'anyOf' at the moment, we create a 'superset object' + SearchResult: + title: SearchResult + properties: + content: + type: string + disabled: + type: boolean + name: + type: string + object_type: + type: string + description: 'set to one of "record, zone, comment"' + zone_id: + type: string + zone: + type: string + type: + type: string + ttl: + type: integer + + SearchResults: + type: array + items: + $ref: '#/definitions/SearchResult' + + Metadata: + title: Metadata + description: Represents zone metadata + properties: + kind: + type: string + description: 'Name of the metadata' + metadata: + type: array + items: + type: string + description: 'Array with all values for this metadata kind.' + + Cryptokey: + title: Cryptokey + description: 'Describes a DNSSEC cryptographic key' + properties: + type: + type: string + description: 'set to "Cryptokey"' + id: + type: integer + description: 'The internal identifier, read only' + keytype: + type: string + enum: [ksk, zsk, csk] + active: + type: boolean + description: 'Whether or not the key is in active use' + published: + type: boolean + description: 'Whether or not the DNSKEY record is published in the zone' + dnskey: + type: string + description: 'The DNSKEY record for this key' + ds: + type: array + items: + type: string + description: 'An array of DS records for this key' + cds: + type: array + items: + type: string + description: 'An array of DS records for this key, filtered by CDS publication settings' + privatekey: + type: string + description: 'The private key in ISC format' + algorithm: + type: string + description: 'The name of the algorithm of the key, should be a mnemonic' + bits: + type: integer + description: 'The size of the key' + + Error: + title: Error + description: 'Returned when the server encounters an error, either in client input or internally' + properties: + error: + type: string + description: 'A human readable error message' + errors: + type: array + items: + type: string + description: 'Optional array of multiple errors encountered during processing' + required: + - error + + CacheFlushResult: + title: CacheFlushResult + description: 'The result of a cache-flush' + properties: + count: + type: number + description: 'Amount of entries flushed' + result: + type: string + description: 'A message about the result like "Flushed cache"' \ No newline at end of file diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java index b865edd43a8..96f912dbd07 100644 --- a/core/src/main/java/google/registry/module/RequestComponent.java +++ b/core/src/main/java/google/registry/module/RequestComponent.java @@ -42,6 +42,8 @@ import google.registry.dns.writer.clouddns.CloudDnsWriterModule; import google.registry.dns.writer.dnsupdate.DnsUpdateConfigModule; import google.registry.dns.writer.dnsupdate.DnsUpdateWriterModule; +import google.registry.dns.writer.powerdns.PowerDnsConfigModule; +import google.registry.dns.writer.powerdns.PowerDnsWriterModule; import google.registry.export.ExportDomainListsAction; import google.registry.export.ExportPremiumTermsAction; import google.registry.export.ExportReservedTermsAction; @@ -148,6 +150,8 @@ DnsModule.class, DnsUpdateConfigModule.class, DnsUpdateWriterModule.class, + PowerDnsConfigModule.class, + PowerDnsWriterModule.class, EppTlsModule.class, EppToolModule.class, IcannReportingModule.class, diff --git a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java index 91a9863a98c..4125bce0655 100644 --- a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java +++ b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java @@ -38,6 +38,8 @@ import google.registry.dns.writer.clouddns.CloudDnsWriterModule; import google.registry.dns.writer.dnsupdate.DnsUpdateConfigModule; import google.registry.dns.writer.dnsupdate.DnsUpdateWriterModule; +import google.registry.dns.writer.powerdns.PowerDnsConfigModule; +import google.registry.dns.writer.powerdns.PowerDnsWriterModule; import google.registry.export.ExportDomainListsAction; import google.registry.export.ExportPremiumTermsAction; import google.registry.export.ExportReservedTermsAction; @@ -89,6 +91,8 @@ DnsModule.class, DnsUpdateConfigModule.class, DnsUpdateWriterModule.class, + PowerDnsConfigModule.class, + PowerDnsWriterModule.class, IcannReportingModule.class, RdeModule.class, ReportingModule.class, diff --git a/core/src/main/java/google/registry/tools/RegistryToolComponent.java b/core/src/main/java/google/registry/tools/RegistryToolComponent.java index fd4b6ddbf38..5aef100085a 100644 --- a/core/src/main/java/google/registry/tools/RegistryToolComponent.java +++ b/core/src/main/java/google/registry/tools/RegistryToolComponent.java @@ -26,6 +26,8 @@ import google.registry.dns.writer.VoidDnsWriterModule; import google.registry.dns.writer.clouddns.CloudDnsWriterModule; import google.registry.dns.writer.dnsupdate.DnsUpdateWriterModule; +import google.registry.dns.writer.powerdns.PowerDnsConfigModule; +import google.registry.dns.writer.powerdns.PowerDnsWriterModule; import google.registry.keyring.KeyringModule; import google.registry.keyring.api.KeyModule; import google.registry.model.ModelModule; @@ -59,6 +61,8 @@ CloudDnsWriterModule.class, CloudTasksUtilsModule.class, DnsUpdateWriterModule.class, + PowerDnsConfigModule.class, + PowerDnsWriterModule.class, GsonModule.class, KeyModule.class, KeyringModule.class, From 4333a55edb409c588d41c35f71a76a58ba51daf9 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Sat, 3 May 2025 09:02:23 -0400 Subject: [PATCH 02/23] inherit base staging logic from DnsUpdateWriter --- .../dns/writer/dnsupdate/DnsUpdateWriter.java | 6 +- .../dns/writer/powerdns/PowerDnsWriter.java | 59 +++++++++++++------ 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/core/src/main/java/google/registry/dns/writer/dnsupdate/DnsUpdateWriter.java b/core/src/main/java/google/registry/dns/writer/dnsupdate/DnsUpdateWriter.java index 86489f3d107..85e2d5cd3ad 100644 --- a/core/src/main/java/google/registry/dns/writer/dnsupdate/DnsUpdateWriter.java +++ b/core/src/main/java/google/registry/dns/writer/dnsupdate/DnsUpdateWriter.java @@ -90,9 +90,9 @@ public class DnsUpdateWriter extends BaseDnsWriter { private final Duration dnsDefaultNsTtl; private final Duration dnsDefaultDsTtl; private final DnsMessageTransport transport; - private final Clock clock; - private final Update update; - private final String zoneName; + protected final Clock clock; + protected final Update update; + protected final String zoneName; /** * Class constructor. diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index ba5dc725330..28279dcb174 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -2,29 +2,31 @@ import com.google.common.flogger.FluentLogger; import google.registry.config.RegistryConfig.Config; -import google.registry.dns.writer.BaseDnsWriter; import google.registry.dns.writer.DnsWriterZone; +import google.registry.dns.writer.dnsupdate.DnsUpdateWriter; import google.registry.dns.writer.powerdns.client.PowerDNSClient; import google.registry.util.Clock; import jakarta.inject.Inject; -import java.util.HashSet; -import java.util.Set; +import org.joda.time.Duration; -/** A DnsWriter that implements the PowerDNS API. */ -public class PowerDnsWriter extends BaseDnsWriter { +/** + * A DnsWriter that sends updates to a PowerDNS backend server. Extends the peer DnsUpdateWriter + * class, which already handles the logic for aggregating DNS changes into a single update request. + * This request is then converted into a PowerDNS Zone object and sent to the PowerDNS API. + */ +public class PowerDnsWriter extends DnsUpdateWriter { public static final String NAME = "PowerDnsWriter"; - private final Clock clock; private final PowerDNSClient powerDnsClient; - private final String zoneName; - private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - private final Set names = new HashSet<>(); /** * Class constructor. * * @param zoneName the name of the zone to write to + * @param dnsDefaultATtl the default TTL for A records + * @param dnsDefaultNsTtl the default TTL for NS records + * @param dnsDefaultDsTtl the default TTL for DS records * @param powerDnsHost the host of the PowerDNS server * @param powerDnsApiKey the API key for the PowerDNS server * @param clock the clock to use for the PowerDNS writer @@ -32,34 +34,53 @@ public class PowerDnsWriter extends BaseDnsWriter { @Inject public PowerDnsWriter( @DnsWriterZone String zoneName, + @Config("dnsDefaultATtl") Duration dnsDefaultATtl, + @Config("dnsDefaultNsTtl") Duration dnsDefaultNsTtl, + @Config("dnsDefaultDsTtl") Duration dnsDefaultDsTtl, @Config("powerDnsHost") String powerDnsHost, @Config("powerDnsApiKey") String powerDnsApiKey, Clock clock) { - // Initialize the PowerDNS client and zone name + // call the DnsUpdateWriter constructor, omitting the transport parameter + // since we don't need it for PowerDNS + super(zoneName, dnsDefaultATtl, dnsDefaultNsTtl, dnsDefaultDsTtl, null, clock); + + // Initialize the PowerDNS client this.powerDnsClient = new PowerDNSClient(powerDnsHost, powerDnsApiKey); - this.zoneName = zoneName; - this.clock = clock; } + /** + * Prepare a domain for staging in PowerDNS. Handles the logic to clean up orphaned glue records + * and adds the domain records to the update. + * + * @param domainName the fully qualified domain name, with no trailing dot + */ @Override public void publishDomain(String domainName) { - // TODO: Implement the logic to stage the domain zone files to PowerDNS for commit - names.add(domainName); + logger.atInfo().log("Staging domain %s for PowerDNS", domainName); + super.publishDomain(domainName); } + /** + * Determine whether the host should be published as a glue record for this zone. If so, add the + * host records to the update. + * + * @param hostName the fully qualified host name, with no trailing dot + */ @Override public void publishHost(String hostName) { - // TODO: Implement the logic to stage the host glue records to PowerDNS for commit - names.add(hostName); + logger.atInfo().log("Staging host %s for PowerDNS", hostName); + super.publishHost(hostName); } @Override protected void commitUnchecked() { + // TODO: Convert the parent class's update object (org.xbill.DNS.Update) into + // a PowerDNS Zone object (google.registry.dns.writer.powerdns.client.Zone) + // TODO: Call the PowerDNS API to commit the changes logger.atWarning().log( - "PowerDnsWriter for server ID %s not yet implemented; ignoring %s names to commit: %s at" - + " %s", - powerDnsClient.getServerId(), zoneName, names, clock.nowUtc()); + "PowerDnsWriter for server ID %s not yet implemented; ignoring zone %s", + powerDnsClient.getServerId(), zoneName); } } From d32c1bcea9585cbdf0497d0bbd49580144a88073 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Sat, 3 May 2025 13:37:23 -0400 Subject: [PATCH 03/23] initial conversion of DNS changes for PowerDNS API usage --- .../dns/writer/powerdns/PowerDnsWriter.java | 105 +++++++++++++++++- .../writer/powerdns/client/model/RRSet.java | 8 +- 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 28279dcb174..3977abb3166 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -5,9 +5,19 @@ import google.registry.dns.writer.DnsWriterZone; import google.registry.dns.writer.dnsupdate.DnsUpdateWriter; import google.registry.dns.writer.powerdns.client.PowerDNSClient; +import google.registry.dns.writer.powerdns.client.model.RRSet; +import google.registry.dns.writer.powerdns.client.model.RecordObject; +import google.registry.dns.writer.powerdns.client.model.Zone; import google.registry.util.Clock; import jakarta.inject.Inject; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; import org.joda.time.Duration; +import org.xbill.DNS.Record; +import org.xbill.DNS.Section; +import org.xbill.DNS.Type; +import org.xbill.DNS.Update; /** * A DnsWriter that sends updates to a PowerDNS backend server. Extends the peer DnsUpdateWriter @@ -75,12 +85,95 @@ public void publishHost(String hostName) { @Override protected void commitUnchecked() { - // TODO: Convert the parent class's update object (org.xbill.DNS.Update) into - // a PowerDNS Zone object (google.registry.dns.writer.powerdns.client.Zone) + try { + // persist staged changes to PowerDNS + logger.atInfo().log( + "Committing updates to PowerDNS for zone %s on server %s", + zoneName, powerDnsClient.getServerId()); - // TODO: Call the PowerDNS API to commit the changes - logger.atWarning().log( - "PowerDnsWriter for server ID %s not yet implemented; ignoring zone %s", - powerDnsClient.getServerId(), zoneName); + // convert the update to a PowerDNS Zone object + Zone zone = convertUpdateToZone(update); + + // call the PowerDNS API to commit the changes + powerDnsClient.patchZone(zone.getId(), zone); + } catch (IOException e) { + throw new RuntimeException("publishDomain failed for zone: " + zoneName, e); + } + } + + /** + * Convert the parent class's update object (org.xbill.DNS.Update) into a PowerDNS Zone object + * (google.registry.dns.writer.powerdns.client.Zone) + * + * @param update the update object to convert + * @return the PowerDNS Zone object + * @throws IOException if the zone is not found + */ + private Zone convertUpdateToZone(Update update) throws IOException { + // Iterate the update records and prepare them as PowerDNS RRSet objects, referencing the + // following source code to determine the usage of the org.xbill.DNS.Record object: + // + // https://www.javadoc.io/doc/dnsjava/dnsjava/3.2.1/org/xbill/DNS/Record.html + // https://github.com/dnsjava/dnsjava/blob/master/src/main/java/org/xbill/DNS/Record.java#L324-L350 + ArrayList updatedRRSets = new ArrayList(); + for (Record r : update.getSection(Section.UPDATE)) { + logger.atInfo().log("Processing zone update record: %s", r); + + // create a PowerDNS RRSet object + RRSet record = new RRSet(); + record.setName(r.getName().toString()); + record.setTtl(r.getTTL()); + record.setType(Type.string(r.getType())); + + // add the record content + RecordObject recordObject = new RecordObject(); + recordObject.setContent(r.rdataToString()); + recordObject.setDisabled(false); + record.setRecords(new ArrayList(Arrays.asList(recordObject))); + + // TODO: need to figure out how to handle the change type of + // the record set. How to handle new and deleted records? + record.setChangeType(RRSet.ChangeType.REPLACE); + + // add the RRSet to the list of updated RRSets + updatedRRSets.add(record); + } + + // retrieve the zone by name and check that it exists + Zone zone = getZoneByName(); + + // prepare the zone for updates + Zone preparedZone = prepareZoneForUpdates(zone); + preparedZone.setRrsets(updatedRRSets); + + // return the prepared zone + return preparedZone; + } + + /** + * Prepare the zone for updates by clearing the RRSets and incrementing the serial number. + * + * @param zone the zone to prepare + * @return the prepared zone + */ + private Zone prepareZoneForUpdates(Zone zone) { + zone.setRrsets(new ArrayList()); + zone.setEditedSerial(zone.getSerial() + 1); + return zone; + } + + /** + * Get the zone by name. + * + * @return the zone + * @throws IOException if the zone is not found + */ + private Zone getZoneByName() throws IOException { + for (Zone zone : powerDnsClient.listZones()) { + if (zone.getName().equals(zoneName)) { + return zone; + } + } + throw new IOException("Zone not found: " + zoneName); } } diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java index 42ea5ad735a..488cf7bd109 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java @@ -11,7 +11,7 @@ public class RRSet { private String type; @JsonProperty("ttl") - private Integer ttl; + private long ttl; @JsonProperty("changetype") private ChangeType changetype; @@ -38,11 +38,11 @@ public void setType(String type) { this.type = type; } - public Integer getTtl() { + public long getTtl() { return ttl; } - public void setTtl(Integer ttl) { + public void setTtl(long ttl) { this.ttl = ttl; } @@ -50,7 +50,7 @@ public ChangeType getChangetype() { return changetype; } - public void setChangetype(ChangeType changetype) { + public void setChangeType(ChangeType changetype) { this.changetype = changetype; } From 5639b21b2bd1be56ba178b9b5205c02761a93aa0 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Mon, 5 May 2025 13:10:52 -0400 Subject: [PATCH 04/23] handle record updates and deletes --- .../dns/writer/powerdns/PowerDnsWriter.java | 114 +++++++++++------- .../writer/powerdns/client/model/RRSet.java | 2 +- .../writer/powerdns/client/model/Zone.java | 10 ++ 3 files changed, 83 insertions(+), 43 deletions(-) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 3977abb3166..8d874378506 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import org.joda.time.Duration; import org.xbill.DNS.Record; import org.xbill.DNS.Section; @@ -27,13 +28,14 @@ public class PowerDnsWriter extends DnsUpdateWriter { public static final String NAME = "PowerDnsWriter"; + private final String tldZoneName; private final PowerDNSClient powerDnsClient; private static final FluentLogger logger = FluentLogger.forEnclosingClass(); /** * Class constructor. * - * @param zoneName the name of the zone to write to + * @param tldZoneName the name of the TLD associated with the update * @param dnsDefaultATtl the default TTL for A records * @param dnsDefaultNsTtl the default TTL for NS records * @param dnsDefaultDsTtl the default TTL for DS records @@ -43,7 +45,7 @@ public class PowerDnsWriter extends DnsUpdateWriter { */ @Inject public PowerDnsWriter( - @DnsWriterZone String zoneName, + @DnsWriterZone String tldZoneName, @Config("dnsDefaultATtl") Duration dnsDefaultATtl, @Config("dnsDefaultNsTtl") Duration dnsDefaultNsTtl, @Config("dnsDefaultDsTtl") Duration dnsDefaultDsTtl, @@ -53,9 +55,10 @@ public PowerDnsWriter( // call the DnsUpdateWriter constructor, omitting the transport parameter // since we don't need it for PowerDNS - super(zoneName, dnsDefaultATtl, dnsDefaultNsTtl, dnsDefaultDsTtl, null, clock); + super(tldZoneName, dnsDefaultATtl, dnsDefaultNsTtl, dnsDefaultDsTtl, null, clock); // Initialize the PowerDNS client + this.tldZoneName = tldZoneName; this.powerDnsClient = new PowerDNSClient(powerDnsHost, powerDnsApiKey); } @@ -88,8 +91,8 @@ protected void commitUnchecked() { try { // persist staged changes to PowerDNS logger.atInfo().log( - "Committing updates to PowerDNS for zone %s on server %s", - zoneName, powerDnsClient.getServerId()); + "Committing updates to PowerDNS for TLD %s on server %s", + tldZoneName, powerDnsClient.getServerId()); // convert the update to a PowerDNS Zone object Zone zone = convertUpdateToZone(update); @@ -97,7 +100,7 @@ protected void commitUnchecked() { // call the PowerDNS API to commit the changes powerDnsClient.patchZone(zone.getId(), zone); } catch (IOException e) { - throw new RuntimeException("publishDomain failed for zone: " + zoneName, e); + throw new RuntimeException("publishDomain failed for TLD: " + tldZoneName, e); } } @@ -115,65 +118,92 @@ private Zone convertUpdateToZone(Update update) throws IOException { // // https://www.javadoc.io/doc/dnsjava/dnsjava/3.2.1/org/xbill/DNS/Record.html // https://github.com/dnsjava/dnsjava/blob/master/src/main/java/org/xbill/DNS/Record.java#L324-L350 - ArrayList updatedRRSets = new ArrayList(); + ArrayList allRRSets = new ArrayList(); + ArrayList filteredRRSets = new ArrayList(); for (Record r : update.getSection(Section.UPDATE)) { - logger.atInfo().log("Processing zone update record: %s", r); + logger.atInfo().log("Processing TLD zone %s update record: %s", tldZoneName, r); - // create a PowerDNS RRSet object + // create the base PowerDNS RRSet object RRSet record = new RRSet(); record.setName(r.getName().toString()); record.setTtl(r.getTTL()); record.setType(Type.string(r.getType())); - // add the record content - RecordObject recordObject = new RecordObject(); - recordObject.setContent(r.rdataToString()); - recordObject.setDisabled(false); - record.setRecords(new ArrayList(Arrays.asList(recordObject))); - - // TODO: need to figure out how to handle the change type of - // the record set. How to handle new and deleted records? - record.setChangeType(RRSet.ChangeType.REPLACE); + // determine if this is a record update or a record deletion + Boolean isDelete = r.getTTL() == 0 && r.rdataToString().equals(""); + + // handle record updates and deletions + if (isDelete) { + // indicate that this is a record deletion + record.setChangeType(RRSet.ChangeType.DELETE); + } else { + // add the record content + RecordObject recordObject = new RecordObject(); + recordObject.setContent(r.rdataToString()); + recordObject.setDisabled(false); + record.setRecords(new ArrayList(Arrays.asList(recordObject))); + + // indicate that this is a record update + record.setChangeType(RRSet.ChangeType.REPLACE); + } - // add the RRSet to the list of updated RRSets - updatedRRSets.add(record); + // Add record to lists of all and filtered RRSets. The first list is used to track all RRSets + // for the TLD zone, while the second list is used to track the RRSets that will be sent to + // the PowerDNS API. By default, there is a deletion record created by the parent class for + // every domain name and record type combination. However, PowerDNS only expects to see a + // deletion record if the record should be removed from the TLD zone. + allRRSets.add(record); + filteredRRSets.add(record); } - // retrieve the zone by name and check that it exists - Zone zone = getZoneByName(); - - // prepare the zone for updates - Zone preparedZone = prepareZoneForUpdates(zone); - preparedZone.setRrsets(updatedRRSets); - - // return the prepared zone - return preparedZone; + // remove deletion records for a domain if there is a subsequent update enqueued + // for the same domain name and record type combination + allRRSets.stream() + .filter(r -> r.getChangeType() == RRSet.ChangeType.REPLACE) + .forEach( + r -> { + filteredRRSets.removeIf( + fr -> + fr.getName().equals(r.getName()) + && fr.getType().equals(r.getType()) + && fr.getChangeType() == RRSet.ChangeType.DELETE); + }); + + // retrieve the TLD zone by name and prepare it for update using the filtered set of + // RRSet records that will be sent to the PowerDNS API + Zone tldZone = getTldZoneByName(); + Zone preparedTldZone = prepareTldZoneForUpdates(tldZone, filteredRRSets); + + // return the prepared TLD zone + logger.atInfo().log("Prepared TLD zone %s for PowerDNS: %s", tldZoneName, preparedTldZone); + return preparedTldZone; } /** - * Prepare the zone for updates by clearing the RRSets and incrementing the serial number. + * Prepare the TLD zone for updates by clearing the RRSets and incrementing the serial number. * - * @param zone the zone to prepare - * @return the prepared zone + * @param tldZone the TLD zone to prepare + * @param records the set of RRSet records that will be sent to the PowerDNS API + * @return the prepared TLD zone */ - private Zone prepareZoneForUpdates(Zone zone) { - zone.setRrsets(new ArrayList()); - zone.setEditedSerial(zone.getSerial() + 1); - return zone; + private Zone prepareTldZoneForUpdates(Zone tldZone, List records) { + tldZone.setRrsets(records); + tldZone.setEditedSerial(tldZone.getSerial() + 1); + return tldZone; } /** - * Get the zone by name. + * Get the TLD zone by name. * - * @return the zone - * @throws IOException if the zone is not found + * @return the TLD zone + * @throws IOException if the TLD zone is not found */ - private Zone getZoneByName() throws IOException { + private Zone getTldZoneByName() throws IOException { for (Zone zone : powerDnsClient.listZones()) { - if (zone.getName().equals(zoneName)) { + if (zone.getName().equals(tldZoneName)) { return zone; } } - throw new IOException("Zone not found: " + zoneName); + throw new IOException("TLD zone not found: " + tldZoneName); } } diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java index 488cf7bd109..3dba7bd1998 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java @@ -46,7 +46,7 @@ public void setTtl(long ttl) { this.ttl = ttl; } - public ChangeType getChangetype() { + public ChangeType getChangeType() { return changetype; } diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java index 08a96c18fe4..a3c581879c0 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java @@ -257,6 +257,16 @@ public void setSlaveTsigKeyIds(List slaveTsigKeyIds) { this.slaveTsigKeyIds = slaveTsigKeyIds; } + @Override + public String toString() { + long deletedCount = + rrsets.stream().filter(rrset -> rrset.getChangeType() == RRSet.ChangeType.DELETE).count(); + long updatedCount = + rrsets.stream().filter(rrset -> rrset.getChangeType() == RRSet.ChangeType.REPLACE).count(); + return String.format( + "{id:%s,name:%s,deleted:%d,updated:%d}", id, name, deletedCount, updatedCount); + } + public enum ZoneKind { Native, Master, From d0bc866380dd8ddb55454b74e7ff32357ee587ba Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Mon, 5 May 2025 13:45:54 -0400 Subject: [PATCH 05/23] add powerDNS API logging --- .../dns/writer/powerdns/PowerDnsWriter.java | 2 +- .../powerdns/client/PowerDNSClient.java | 41 ++++++++++++++----- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 8d874378506..88a8e7c3d78 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -98,7 +98,7 @@ protected void commitUnchecked() { Zone zone = convertUpdateToZone(update); // call the PowerDNS API to commit the changes - powerDnsClient.patchZone(zone.getId(), zone); + powerDnsClient.patchZone(zone); } catch (IOException e) { throw new RuntimeException("publishDomain failed for TLD: " + tldZoneName, e); } diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java index adad1e40bf8..22f6a273869 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -1,6 +1,7 @@ package google.registry.dns.writer.powerdns.client; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.flogger.FluentLogger; import google.registry.dns.writer.powerdns.client.model.Server; import google.registry.dns.writer.powerdns.client.model.Zone; import java.io.IOException; @@ -14,6 +15,7 @@ public class PowerDNSClient { // static fields + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private final OkHttpClient httpClient; private final ObjectMapper objectMapper; private final String baseUrl; @@ -42,11 +44,30 @@ public PowerDNSClient(String baseUrl, String apiKey) { } } + private Response logAndExecuteRequest(Request request) throws IOException { + // log the request and create timestamp for the start time + logger.atInfo().log("Executing PowerDNS request: %s, body: %s", request, request.body()); + long startTime = System.currentTimeMillis(); + + // execute the request and log the response + Response response = httpClient.newCall(request).execute(); + logger.atInfo().log("PowerDNS response: %s", response); + + // log the response time and response code + long endTime = System.currentTimeMillis(); + logger.atInfo().log( + "Completed PowerDNS request in %d ms, success: %s, response code: %d", + endTime - startTime, response.isSuccessful(), response.code()); + + // return the response + return response; + } + public List listServers() throws IOException { Request request = new Request.Builder().url(baseUrl + "/servers").header("X-API-Key", apiKey).get().build(); - try (Response response = httpClient.newCall(request).execute()) { + try (Response response = logAndExecuteRequest(request)) { if (!response.isSuccessful()) { throw new IOException("Failed to list servers: " + response); } @@ -64,7 +85,7 @@ public Server getServer() throws IOException { .get() .build(); - try (Response response = httpClient.newCall(request).execute()) { + try (Response response = logAndExecuteRequest(request)) { if (!response.isSuccessful()) { throw new IOException("Failed to get server: " + response); } @@ -88,7 +109,7 @@ public List listZones() throws IOException { .get() .build(); - try (Response response = httpClient.newCall(request).execute()) { + try (Response response = logAndExecuteRequest(request)) { if (!response.isSuccessful()) { throw new IOException("Failed to list zones: " + response); } @@ -106,7 +127,7 @@ public Zone getZone(String zoneId) throws IOException { .get() .build(); - try (Response response = httpClient.newCall(request).execute()) { + try (Response response = logAndExecuteRequest(request)) { if (!response.isSuccessful()) { throw new IOException("Failed to get zone: " + response); } @@ -125,7 +146,7 @@ public Zone createZone(Zone zone) throws IOException { .post(body) .build(); - try (Response response = httpClient.newCall(request).execute()) { + try (Response response = logAndExecuteRequest(request)) { if (!response.isSuccessful()) { throw new IOException("Failed to create zone: " + response); } @@ -141,25 +162,25 @@ public void deleteZone(String zoneId) throws IOException { .delete() .build(); - try (Response response = httpClient.newCall(request).execute()) { + try (Response response = logAndExecuteRequest(request)) { if (!response.isSuccessful()) { throw new IOException("Failed to delete zone: " + response); } } } - public void patchZone(String zoneId, Zone zone) throws IOException { + public void patchZone(Zone zone) throws IOException { String json = objectMapper.writeValueAsString(zone); RequestBody body = RequestBody.create(json, MediaType.parse("application/json")); Request request = new Request.Builder() - .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId) + .url(baseUrl + "/servers/" + serverId + "/zones/" + zone.getId()) .header("X-API-Key", apiKey) .patch(body) .build(); - try (Response response = httpClient.newCall(request).execute()) { + try (Response response = logAndExecuteRequest(request)) { if (!response.isSuccessful()) { throw new IOException("Failed to patch zone: " + response); } @@ -174,7 +195,7 @@ public void notifyZone(String zoneId) throws IOException { .put(RequestBody.create("", MediaType.parse("application/json"))) .build(); - try (Response response = httpClient.newCall(request).execute()) { + try (Response response = logAndExecuteRequest(request)) { if (!response.isSuccessful()) { throw new IOException("Failed to notify zone: " + response); } From 4a45d9e5d01d654da6ac4381028de35d6cdca9d5 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Mon, 5 May 2025 15:00:28 -0400 Subject: [PATCH 06/23] add license headers --- .../writer/powerdns/PowerDnsConfigModule.java | 14 + .../dns/writer/powerdns/PowerDnsWriter.java | 14 + .../writer/powerdns/PowerDnsWriterModule.java | 14 + .../powerdns/client/PowerDNSClient.java | 14 + .../writer/powerdns/client/model/Comment.java | 14 + .../writer/powerdns/client/model/RRSet.java | 14 + .../powerdns/client/model/RecordObject.java | 14 + .../writer/powerdns/client/model/Server.java | 14 + .../writer/powerdns/client/model/Zone.java | 14 + .../openapi/authoritative-api-swagger.yaml | 1398 ----------------- 10 files changed, 126 insertions(+), 1398 deletions(-) delete mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/openapi/authoritative-api-swagger.yaml diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java index 130e31f7844..b9eb16d6a01 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java @@ -1,3 +1,17 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns; import dagger.Module; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 88a8e7c3d78..8ae1a48008f 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -1,3 +1,17 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns; import com.google.common.flogger.FluentLogger; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriterModule.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriterModule.java index 2c2de487828..ffa99b2b6ef 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriterModule.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriterModule.java @@ -1,3 +1,17 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns; import dagger.Binds; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java index 22f6a273869..3cfa23e52a0 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -1,3 +1,17 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns.client; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java index 32d9cb87db3..ba49afa7d01 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java @@ -1,3 +1,17 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns.client.model; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java index 3dba7bd1998..0620fb757fd 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java @@ -1,3 +1,17 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns.client.model; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java index ffe3c2e40cf..1ad33e4f209 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java @@ -1,3 +1,17 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns.client.model; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java index 135f488a4ae..024ee40946a 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java @@ -1,3 +1,17 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns.client.model; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java index a3c581879c0..ddc406510ad 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java @@ -1,3 +1,17 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns.client.model; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/openapi/authoritative-api-swagger.yaml b/core/src/main/java/google/registry/dns/writer/powerdns/client/openapi/authoritative-api-swagger.yaml deleted file mode 100644 index 555fe70c2b7..00000000000 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/openapi/authoritative-api-swagger.yaml +++ /dev/null @@ -1,1398 +0,0 @@ -swagger: '2.0' -info: - version: "0.0.15" - title: PowerDNS Authoritative HTTP API - license: - name: MIT -basePath: /api/v1 -consumes: - - application/json -produces: - - application/json -securityDefinitions: - # X-API-Key: abcdef12345 - APIKeyHeader: - type: apiKey - in: header - name: X-API-Key -security: - - APIKeyHeader: [] - -# Overall TODOS: -# TODO: Return types are not consistent across documentation -# We need to look at the code and figure out the default HTTP response -# codes and adjust docs accordingly. -paths: - '/error': - get: - summary: Will always generate an error - operationId: error - responses: &commonErrors - '400': - description: The supplied request was not valid - schema: - $ref: '#/definitions/Error' - '404': - description: Requested item was not found - schema: - $ref: '#/definitions/Error' - '422': - description: The input to the operation was not valid - schema: - $ref: '#/definitions/Error' - '500': - description: Internal server error - schema: - $ref: '#/definitions/Error' - - '/servers': - get: - summary: List all servers - operationId: listServers - tags: - - servers - responses: - '200': - description: An array of servers - schema: - type: array - items: - $ref: '#/definitions/Server' - <<: *commonErrors - - '/servers/{server_id}': - get: - summary: List a server - operationId: listServer - tags: - - servers - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - responses: - '200': - description: An server - schema: - $ref: '#/definitions/Server' - <<: *commonErrors - - '/servers/{server_id}/cache/flush': - put: - summary: Flush a cache-entry by name - operationId: cacheFlushByName - tags: - - servers - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: domain - in: query - required: true - description: The domain name to flush from the cache - type: string - responses: - '200': - description: Flush successful - schema: - $ref: '#/definitions/CacheFlushResult' - <<: *commonErrors - - '/servers/{server_id}/zones': - get: - summary: List all Zones in a server - operationId: listZones - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone - in: query - required: false - type: string - description: | - When set to the name of a zone, only this zone is returned. - If no zone with that name exists, the response is an empty array. - This can e.g. be used to check if a zone exists in the database without having to guess/encode the zone's id or to check if a zone exists. - - name: dnssec - in: query - required: false - type: boolean - default: true - description: '“true” (default) or “false”, whether to include the “dnssec” and ”edited_serial” fields in the Zone objects. Setting this to ”false” will make the query a lot faster.' - responses: - '200': - description: An array of Zones - schema: - type: array - items: - $ref: '#/definitions/Zone' - <<: *commonErrors - post: - summary: Creates a new domain, returns the Zone on creation. - operationId: createZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: rrsets - in: query - description: '“true” (default) or “false”, whether to include the “rrsets” in the response Zone object.' - type: boolean - default: true - - name: zone_struct - description: The zone struct to patch with - required: true - in: body - schema: - $ref: '#/definitions/Zone' - responses: - '201': - description: A zone - schema: - $ref: '#/definitions/Zone' - <<: *commonErrors - - '/servers/{server_id}/zones/{zone_id}': - get: - summary: zone managed by a server - operationId: listZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - - name: rrsets - in: query - description: '“true” (default) or “false”, whether to include the “rrsets” in the response Zone object.' - type: boolean - default: true - - name: rrset_name - in: query - description: Limit output to RRsets for this name. - type: string - - name: rrset_type - in: query - description: Limit output to the RRset of this type. Can only be used together with rrset_name. - type: string - - name: include_disabled - in: query - description: '“true” (default) or “false”, whether to include disabled RRsets in the response.' - type: boolean - responses: - '200': - description: A Zone - schema: - $ref: '#/definitions/Zone' - <<: *commonErrors - delete: - summary: Deletes this zone, all attached metadata and rrsets. - operationId: deleteZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '204': - description: 'Returns 204 No Content on success.' - <<: *commonErrors - patch: - summary: 'Creates/modifies/deletes RRsets present in the payload and their comments. Returns 204 No Content on success.' - operationId: patchZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: zone_struct - description: The zone struct to patch with - required: true - in: body - schema: - $ref: '#/definitions/Zone' - responses: - '204': - description: 'Returns 204 No Content on success.' - <<: *commonErrors - - put: - summary: Modifies basic zone data. - description: 'The only fields in the zone structure which can be modified are: kind, masters, catalog, account, soa_edit, soa_edit_api, api_rectify, dnssec, and nsec3param. All other fields are ignored.' - operationId: putZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: zone_struct - description: The zone struct to patch with - required: true - in: body - schema: - $ref: '#/definitions/Zone' - responses: - '204': - description: 'Returns 204 No Content on success.' - <<: *commonErrors - - '/servers/{server_id}/zones/{zone_id}/notify': - put: - summary: Send a DNS NOTIFY to all slaves. - description: 'Fails when zone kind is not Master or Slave, or master and slave are disabled in the configuration. Only works for Slave if renotify is on. Clients MUST NOT send a body.' - operationId: notifyZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: OK - <<: *commonErrors - - '/servers/{server_id}/zones/{zone_id}/axfr-retrieve': - put: - summary: Retrieve slave zone from its master. - description: 'Fails when zone kind is not Slave, or slave is disabled in the configuration. Clients MUST NOT send a body.' - operationId: axfrRetrieveZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: OK - <<: *commonErrors - - '/servers/{server_id}/zones/{zone_id}/export': - get: - summary: 'Returns the zone in AXFR format.' - operationId: axfrExportZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: OK - schema: - type: string - <<: *commonErrors - - '/servers/{server_id}/zones/{zone_id}/rectify': - put: - summary: 'Rectify the zone data.' - description: 'This does not take into account the API-RECTIFY metadata. Fails on slave zones and zones that do not have DNSSEC.' - operationId: rectifyZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: OK - schema: - type: string - <<: *commonErrors - - '/servers/{server_id}/config': - get: - summary: 'Returns all ConfigSettings for a single server' - operationId: getConfig - tags: - - config - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - responses: - '200': - description: List of config values - schema: - type: array - items: - $ref: '#/definitions/ConfigSetting' - <<: *commonErrors - - '/servers/{server_id}/config/{config_setting_name}': - get: - summary: 'Returns a specific ConfigSetting for a single server' - description: 'NOT IMPLEMENTED' - operationId: getConfigSetting - tags: - - config - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: config_setting_name - in: path - required: true - description: The name of the setting to retrieve - type: string - responses: - '200': - description: List of config values - schema: - $ref: '#/definitions/ConfigSetting' - <<: *commonErrors - - '/servers/{server_id}/statistics': - get: - summary: 'Query statistics.' - description: 'Query PowerDNS internal statistics.' - operationId: getStats - tags: - - stats - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: statistic - in: query - required: false - type: string - description: | - When set to the name of a specific statistic, only this value is returned. - If no statistic with that name exists, the response has a 422 status and an error message. - - name: includerings - in: query - required: false - type: boolean - default: true - description: '“true” (default) or “false”, whether to include the Ring items, which can contain thousands of log messages or queried domains. Setting this to ”false” may make the response a lot smaller.' - responses: - '200': - description: List of Statistic Items - schema: - type: array - items: - - $ref: '#/definitions/StatisticItem' - - $ref: '#/definitions/MapStatisticItem' - - $ref: '#/definitions/RingStatisticItem' - '422': - description: 'Returned when a non-existing statistic name has been requested. Contains an error message' - <<: *commonErrors - - '/servers/{server_id}/search-data': - get: - summary: 'Search the data inside PowerDNS' - description: 'Search the data inside PowerDNS for search_term and return at most max_results. This includes zones, records and comments. The * character can be used in search_term as a wildcard character and the ? character can be used as a wildcard for a single character.' - operationId: searchData - tags: - - search - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: q - in: query - required: true - description: 'The string to search for' - type: string - - name: max - in: query - required: true - description: 'Maximum number of entries to return' - type: integer - - name: object_type - in: query - required: false - description: 'Type of data to search for, one of “all”, “zone”, “record”, “comment”' - type: string - responses: - '200': - description: Returns a JSON array with results - schema: - $ref: '#/definitions/SearchResults' - <<: *commonErrors - - '/servers/{server_id}/zones/{zone_id}/metadata': - get: - summary: 'Get all the Metadata associated with the zone.' - operationId: listMetadata - tags: - - zonemetadata - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: List of Metadata objects - schema: - type: array - items: - $ref: '#/definitions/Metadata' - <<: *commonErrors - post: - summary: 'Creates a set of metadata entries' - description: 'Creates a set of metadata entries of given kind for the zone. Existing metadata entries for the zone with the same kind are not overwritten.' - operationId: createMetadata - tags: - - zonemetadata - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: metadata - description: Metadata object with list of values to create - required: true - in: body - schema: - $ref: '#/definitions/Metadata' - responses: - '204': - description: OK - <<: *commonErrors - - '/servers/{server_id}/zones/{zone_id}/metadata/{metadata_kind}': - get: - summary: 'Get the content of a single kind of domain metadata as a Metadata object.' - operationId: getMetadata - tags: - - zonemetadata - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - - name: metadata_kind - type: string - in: path - required: true - description: The kind of metadata - responses: - '200': - description: Metadata object with list of values - schema: - $ref: '#/definitions/Metadata' - <<: *commonErrors - put: - summary: 'Replace the content of a single kind of domain metadata.' - description: 'Creates a set of metadata entries of given kind for the zone. Existing metadata entries for the zone with the same kind are removed.' - operationId: modifyMetadata - tags: - - zonemetadata - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: metadata_kind - description: The kind of metadata - required: true - type: string - in: path - - name: metadata - description: metadata to add/create - required: true - in: body - schema: - $ref: '#/definitions/Metadata' - responses: - '200': - description: Metadata object with list of values - schema: - $ref: '#/definitions/Metadata' - <<: *commonErrors - delete: - summary: 'Delete all items of a single kind of domain metadata.' - operationId: deleteMetadata - tags: - - zonemetadata - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - - name: metadata_kind - type: string - in: path - required: true - description: The kind of metadata - responses: - '204': - description: OK - <<: *commonErrors - - '/servers/{server_id}/zones/{zone_id}/cryptokeys': - get: - summary: 'Get all CryptoKeys for a zone, except the privatekey' - operationId: listCryptokeys - tags: - - zonecryptokey - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: List of Cryptokey objects - schema: - type: array - items: - $ref: '#/definitions/Cryptokey' - <<: *commonErrors - post: - summary: 'Creates a Cryptokey' - description: 'This method adds a new key to a zone. The key can either be generated or imported by supplying the content parameter. if content, bits and algo are null, a key will be generated based on the default-ksk-algorithm and default-ksk-size settings for a KSK and the default-zsk-algorithm and default-zsk-size options for a ZSK.' - operationId: createCryptokey - tags: - - zonecryptokey - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: cryptokey - description: Add a Cryptokey - required: true - in: body - schema: - $ref: '#/definitions/Cryptokey' - responses: - '201': - description: Created - schema: - $ref: '#/definitions/Cryptokey' - <<: *commonErrors - - '/servers/{server_id}/zones/{zone_id}/cryptokeys/{cryptokey_id}': - get: - summary: 'Returns all data about the CryptoKey, including the privatekey.' - operationId: getCryptokey - tags: - - zonecryptokey - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - - name: cryptokey_id - type: string - in: path - required: true - description: 'The id value of the CryptoKey' - responses: - '200': - description: Cryptokey - schema: - $ref: '#/definitions/Cryptokey' - <<: *commonErrors - put: - summary: 'This method (de)activates a key from zone_name specified by cryptokey_id' - operationId: modifyCryptokey - tags: - - zonecryptokey - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: cryptokey_id - description: Cryptokey to manipulate - required: true - in: path - type: string - - name: cryptokey - description: the Cryptokey - required: true - in: body - schema: - $ref: '#/definitions/Cryptokey' - responses: - '204': - description: OK - <<: *commonErrors - delete: - summary: 'This method deletes a key specified by cryptokey_id.' - operationId: deleteCryptokey - tags: - - zonecryptokey - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - - name: cryptokey_id - type: string - in: path - required: true - description: 'The id value of the Cryptokey' - responses: - '204': - description: OK - <<: *commonErrors - - '/servers/{server_id}/tsigkeys': - parameters: - - name: server_id - in: path - required: true - description: 'The id of the server' - type: string - get: - summary: 'Get all TSIGKeys on the server, except the actual key' - operationId: listTSIGKeys - tags: - - tsigkey - responses: - '200': - description: List of TSIGKey objects - schema: - type: array - items: - $ref: '#/definitions/TSIGKey' - <<: *commonErrors - post: - summary: 'Add a TSIG key' - description: 'This methods add a new TSIGKey. The actual key can be generated by the server or be provided by the client' - operationId: createTSIGKey - tags: - - tsigkey - parameters: - - name: tsigkey - description: The TSIGKey to add - required: true - in: body - schema: - $ref: '#/definitions/TSIGKey' - responses: - '201': - description: Created - schema: - $ref: '#/definitions/TSIGKey' - '409': - description: An item with this name already exists - schema: - $ref: '#/definitions/Error' - <<: *commonErrors - - '/servers/{server_id}/tsigkeys/{tsigkey_id}': - parameters: - - name: server_id - in: path - required: true - description: 'The id of the server to retrieve the key from' - type: string - - name: tsigkey_id - in: path - required: true - description: 'The id of the TSIGkey. Should match the "id" field in the TSIGKey object' - type: string - get: - summary: 'Get a specific TSIGKeys on the server, including the actual key' - operationId: getTSIGKey - tags: - - tsigkey - responses: - '200': - description: OK. - schema: - $ref: '#/definitions/TSIGKey' - <<: *commonErrors - put: - description: | - The TSIGKey at tsigkey_id can be changed in multiple ways: - * Changing the Name, this will remove the key with tsigkey_id after adding. - * Changing the Algorithm - * Changing the Key - - Only the relevant fields have to be provided in the request body. - operationId: putTSIGKey - tags: - - tsigkey - parameters: - - name: tsigkey - description: A (possibly stripped down) TSIGKey object with the new values - schema: - $ref: '#/definitions/TSIGKey' - in: body - required: true - responses: - '200': - description: OK. TSIGKey is changed. - schema: - $ref: '#/definitions/TSIGKey' - '409': - description: An item with this name already exists - schema: - $ref: '#/definitions/Error' - <<: *commonErrors - delete: - summary: 'Delete the TSIGKey with tsigkey_id' - operationId: deleteTSIGKey - tags: - - tsigkey - responses: - '204': - description: 'OK, key was deleted' - <<: *commonErrors - - '/servers/{server_id}/autoprimaries': - parameters: - - name: server_id - in: path - required: true - description: 'The id of the server to manage the list of autoprimaries on' - type: string - get: - summary: 'Get a list of autoprimaries' - operationId: getAutoprimaries - tags: - - autoprimary - responses: - '200': - description: OK. - schema: - $ref: '#/definitions/Autoprimary' - <<: *commonErrors - post: - summary: 'Add an autoprimary' - description: 'This methods add a new autoprimary server.' - operationId: createAutoprimary - tags: - - autoprimary - parameters: - - name: autoprimary - description: autoprimary entry to add - required: true - in: body - schema: - $ref: '#/definitions/Autoprimary' - responses: - '201': - description: Created - <<: *commonErrors - - '/servers/{server_id}/autoprimaries/{ip}/{nameserver}': - parameters: - - name: server_id - in: path - required: true - description: 'The id of the server to delete the autoprimary from' - type: string - - name: ip - in: path - required: true - description: 'IP address of autoprimary' - type: string - - name: nameserver - in: path - required: true - description: 'DNS name of the autoprimary' - type: string - delete: - summary: 'Delete the autoprimary entry' - operationId: deleteAutoprimary - tags: - - autoprimary - responses: - '204': - description: 'OK, key was deleted' - <<: *commonErrors - -definitions: - Server: - title: Server - properties: - type: - type: string - description: 'Set to “Server”' - id: - type: string - description: 'The id of the server, “localhost”' - daemon_type: - type: string - description: '“recursor” for the PowerDNS Recursor and “authoritative” for the Authoritative Server' - version: - type: string - description: 'The version of the server software' - url: - type: string - description: 'The API endpoint for this server' - config_url: - type: string - description: 'The API endpoint for this server’s configuration' - zones_url: - type: string - description: 'The API endpoint for this server’s zones' - - Servers: - type: array - items: - $ref: '#/definitions/Server' - - Zone: - title: Zone - description: This represents an authoritative DNS Zone. - properties: - id: - type: string - description: 'Opaque zone id (string), assigned by the server, should not be interpreted by the application. Guaranteed to be safe for embedding in URLs.' - name: - type: string - description: 'Name of the zone (e.g. “example.com.”) MUST have a trailing dot' - type: - type: string - description: 'Set to “Zone”' - url: - type: string - description: 'API endpoint for this zone' - kind: - type: string - enum: - - 'Native' - - 'Master' - - 'Slave' - - 'Producer' - - 'Consumer' - description: 'Zone kind, one of “Native”, “Master”, “Slave”, “Producer”, “Consumer”' - rrsets: - type: array - items: - $ref: '#/definitions/RRSet' - description: 'RRSets in this zone (for zones/{zone_id} endpoint only; omitted during GET on the .../zones list endpoint)' - serial: - type: integer - description: 'The SOA serial number' - notified_serial: - type: integer - description: 'The SOA serial notifications have been sent out for' - edited_serial: - type: integer - description: 'The SOA serial as seen in query responses. Calculated using the SOA-EDIT metadata, default-soa-edit and default-soa-edit-signed settings' - masters: - type: array - items: - type: string - description: ' List of IP addresses configured as a master for this zone (“Slave” type zones only)' - dnssec: - type: boolean - description: 'Whether or not this zone is DNSSEC signed (inferred from presigned being true XOR presence of at least one cryptokey with active being true)' - nsec3param: - type: string - description: 'The NSEC3PARAM record' - nsec3narrow: - type: boolean - description: 'Whether or not the zone uses NSEC3 narrow' - presigned: - type: boolean - description: 'Whether or not the zone is pre-signed' - soa_edit: - type: string - description: 'The SOA-EDIT metadata item' - soa_edit_api: - type: string - description: 'The SOA-EDIT-API metadata item' - api_rectify: - type: boolean - description: 'Whether or not the zone will be rectified on data changes via the API' - zone: - type: string - description: 'MAY contain a BIND-style zone file when creating a zone' - catalog: - type: string - description: 'The catalog this zone is a member of' - account: - type: string - description: 'MAY be set. Its value is defined by local policy' - nameservers: - type: array - items: - type: string - description: 'MAY be sent in client bodies during creation, and MUST NOT be sent by the server. Simple list of strings of nameserver names, including the trailing dot. Not required for slave zones.' - master_tsig_key_ids: - type: array - items: - type: string - description: 'The id of the TSIG keys used for master operation in this zone' - externalDocs: - url: 'https://doc.powerdns.com/authoritative/tsig.html#provisioning-outbound-axfr-access' - slave_tsig_key_ids: - type: array - items: - type: string - description: 'The id of the TSIG keys used for slave operation in this zone' - externalDocs: - url: 'https://doc.powerdns.com/authoritative/tsig.html#provisioning-signed-notification-and-axfr-requests' - - Zones: - type: array - items: - $ref: '#/definitions/Zone' - - RRSet: - title: RRSet - description: This represents a Resource Record Set (all records with the same name and type). - required: - - name - - type - - ttl - - changetype - - records - properties: - name: - type: string - description: 'Name for record set (e.g. “www.powerdns.com.”)' - type: - type: string - description: 'Type of this record (e.g. “A”, “PTR”, “MX”)' - ttl: - type: integer - description: 'DNS TTL of the records, in seconds. MUST NOT be included when changetype is set to “DELETE”.' - changetype: - type: string - description: 'MUST be added when updating the RRSet. Must be REPLACE or DELETE. With DELETE, all existing RRs matching name and type will be deleted, including all comments. With REPLACE: when records is present, all existing RRs matching name and type will be deleted, and then new records given in records will be created. If no records are left, any existing comments will be deleted as well. When comments is present, all existing comments for the RRs matching name and type will be deleted, and then new comments given in comments will be created.' - records: - type: array - description: 'All records in this RRSet. When updating Records, this is the list of new records (replacing the old ones). Must be empty when changetype is set to DELETE. An empty list results in deletion of all records (and comments).' - items: - $ref: '#/definitions/Record' - comments: - type: array - description: 'List of Comment. Must be empty when changetype is set to DELETE. An empty list results in deletion of all comments. modified_at is optional and defaults to the current server time.' - items: - $ref: '#/definitions/Comment' - - Record: - title: Record - description: The RREntry object represents a single record. - required: - - content - properties: - content: - type: string - description: 'The content of this record' - disabled: - type: boolean - description: 'Whether or not this record is disabled. When unset, the record is not disabled' - - Comment: - title: Comment - description: A comment about an RRSet. - properties: - content: - type: string - description: 'The actual comment' - account: - type: string - description: 'Name of an account that added the comment' - modified_at: - type: integer - description: 'Timestamp of the last change to the comment' - - TSIGKey: - title: TSIGKey - description: A TSIG key that can be used to authenticate NOTIFY, AXFR, and DNSUPDATE queries. - properties: - name: - type: string - description: 'The name of the key' - id: - type: string - description: 'The ID for this key, used in the TSIGkey URL endpoint.' - readOnly: true - algorithm: - type: string - description: 'The algorithm of the TSIG key' - key: - type: string - description: 'The Base64 encoded secret key, empty when listing keys. MAY be empty when POSTing to have the server generate the key material' - type: - type: string - description: 'Set to "TSIGKey"' - readOnly: true - - Autoprimary: - title: Autoprimary server - description: An autoprimary server that can provision new domains. - properties: - ip: - type: string - description: "IP address of the autoprimary server" - nameserver: - type: string - description: "DNS name of the autoprimary server" - account: - type: string - description: "Account name for the autoprimary server" - - ConfigSetting: - title: ConfigSetting - properties: - name: - type: string - description: 'set to "ConfigSetting"' - type: - type: string - description: 'The name of this setting (e.g. ‘webserver-port’)' - value: - type: string - description: 'The value of setting name' - - SimpleStatisticItem: - title: SimpleStatisticItem - type: object - properties: - name: - type: string - description: 'Item name' - value: - type: string - description: 'Item value' - - StatisticItem: - title: StatisticItem - properties: - name: - type: string - description: 'Item name' - type: - type: string - description: 'set to "StatisticItem"' - value: - type: string - description: 'Item value' - - MapStatisticItem: - title: MapStatisticItem - properties: - name: - type: string - description: 'Item name' - type: - type: string - description: 'Set to "MapStatisticItem"' - value: - type: array - description: 'Named values' - items: - $ref: '#/definitions/SimpleStatisticItem' - - RingStatisticItem: - title: RingStatisticItem - properties: - name: - type: string - description: 'Item name' - type: - type: string - description: 'Set to "RingStatisticItem"' - size: - type: integer - description: 'Ring size' - value: - type: array - description: 'Named values' - items: - $ref: '#/definitions/SimpleStatisticItem' - - SearchResultZone: - title: SearchResultZone - properties: - name: - type: string - object_type: - type: string - description: 'set to "zone"' - zone_id: - type: string - - SearchResultRecord: - title: SearchResultRecord - properties: - content: - type: string - disabled: - type: boolean - name: - type: string - object_type: - type: string - description: 'set to "record"' - zone_id: - type: string - zone: - type: string - type: - type: string - ttl: - type: integer - - SearchResultComment: - title: SearchResultComment - properties: - content: - type: string - name: - type: string - object_type: - type: string - description: 'set to "comment"' - zone_id: - type: string - zone: - type: string - -# FIXME: This is problematic at the moment, because swagger doesn't support this type of mixed response -# SearchResult: -# anyOf: -# - $ref: '#/definitions/SearchResultZone' -# - $ref: '#/definitions/SearchResultRecord' -# - $ref: '#/definitions/SearchResultComment' - -# Since we can't do 'anyOf' at the moment, we create a 'superset object' - SearchResult: - title: SearchResult - properties: - content: - type: string - disabled: - type: boolean - name: - type: string - object_type: - type: string - description: 'set to one of "record, zone, comment"' - zone_id: - type: string - zone: - type: string - type: - type: string - ttl: - type: integer - - SearchResults: - type: array - items: - $ref: '#/definitions/SearchResult' - - Metadata: - title: Metadata - description: Represents zone metadata - properties: - kind: - type: string - description: 'Name of the metadata' - metadata: - type: array - items: - type: string - description: 'Array with all values for this metadata kind.' - - Cryptokey: - title: Cryptokey - description: 'Describes a DNSSEC cryptographic key' - properties: - type: - type: string - description: 'set to "Cryptokey"' - id: - type: integer - description: 'The internal identifier, read only' - keytype: - type: string - enum: [ksk, zsk, csk] - active: - type: boolean - description: 'Whether or not the key is in active use' - published: - type: boolean - description: 'Whether or not the DNSKEY record is published in the zone' - dnskey: - type: string - description: 'The DNSKEY record for this key' - ds: - type: array - items: - type: string - description: 'An array of DS records for this key' - cds: - type: array - items: - type: string - description: 'An array of DS records for this key, filtered by CDS publication settings' - privatekey: - type: string - description: 'The private key in ISC format' - algorithm: - type: string - description: 'The name of the algorithm of the key, should be a mnemonic' - bits: - type: integer - description: 'The size of the key' - - Error: - title: Error - description: 'Returned when the server encounters an error, either in client input or internally' - properties: - error: - type: string - description: 'A human readable error message' - errors: - type: array - items: - type: string - description: 'Optional array of multiple errors encountered during processing' - required: - - error - - CacheFlushResult: - title: CacheFlushResult - description: 'The result of a cache-flush' - properties: - count: - type: number - description: 'Amount of entries flushed' - result: - type: string - description: 'A message about the result like "Flushed cache"' \ No newline at end of file From 4fc4a7212cdbeda04c6f1cefda70c5d747eed01e Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Tue, 6 May 2025 09:06:20 -0400 Subject: [PATCH 07/23] only fetch zone details when necessary --- .../dns/writer/powerdns/PowerDnsWriter.java | 35 +++++++++++++++---- .../powerdns/client/PowerDNSClient.java | 5 ++- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 8ae1a48008f..b2fe76d00a8 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -28,6 +28,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; import org.joda.time.Duration; import org.xbill.DNS.Record; import org.xbill.DNS.Section; @@ -45,6 +46,7 @@ public class PowerDnsWriter extends DnsUpdateWriter { private final String tldZoneName; private final PowerDNSClient powerDnsClient; private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final ConcurrentHashMap zoneIdCache = new ConcurrentHashMap<>(); /** * Class constructor. @@ -183,10 +185,8 @@ private Zone convertUpdateToZone(Update update) throws IOException { && fr.getChangeType() == RRSet.ChangeType.DELETE); }); - // retrieve the TLD zone by name and prepare it for update using the filtered set of - // RRSet records that will be sent to the PowerDNS API - Zone tldZone = getTldZoneByName(); - Zone preparedTldZone = prepareTldZoneForUpdates(tldZone, filteredRRSets); + // prepare a PowerDNS zone object containing the TLD record updates + Zone preparedTldZone = getTldZoneForUpdate(filteredRRSets); // return the prepared TLD zone logger.atInfo().log("Prepared TLD zone %s for PowerDNS: %s", tldZoneName, preparedTldZone); @@ -196,13 +196,13 @@ private Zone convertUpdateToZone(Update update) throws IOException { /** * Prepare the TLD zone for updates by clearing the RRSets and incrementing the serial number. * - * @param tldZone the TLD zone to prepare * @param records the set of RRSet records that will be sent to the PowerDNS API * @return the prepared TLD zone */ - private Zone prepareTldZoneForUpdates(Zone tldZone, List records) { + private Zone getTldZoneForUpdate(List records) { + Zone tldZone = new Zone(); + tldZone.setId(getTldZoneId()); tldZone.setRrsets(records); - tldZone.setEditedSerial(tldZone.getSerial() + 1); return tldZone; } @@ -220,4 +220,25 @@ private Zone getTldZoneByName() throws IOException { } throw new IOException("TLD zone not found: " + tldZoneName); } + + /** + * Get the TLD zone ID for the given TLD zone name from the cache, or compute it if it is not + * present in the cache. + * + * @return the ID of the TLD zone + */ + private String getTldZoneId() { + return zoneIdCache.computeIfAbsent( + tldZoneName, + key -> { + try { + return getTldZoneByName().getId(); + } catch (IOException e) { + // TODO: throw this exception once PowerDNS is available, but for now we are just + // going to return a dummy ID + logger.atWarning().log("Failed to get TLD zone ID for %s: %s", tldZoneName, e); + return String.format("dummy-zone-id-%s", tldZoneName); + } + }); + } } diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java index 3cfa23e52a0..d794978a340 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -54,7 +54,10 @@ public PowerDNSClient(String baseUrl, String apiKey) { } this.serverId = servers.get(0).getId(); } catch (IOException e) { - this.serverId = "unknown-server-id"; + // TODO: throw this exception once PowerDNS is available, but for now we are just + // going to return a dummy ID + logger.atWarning().log("Failed to get server ID: %s", e); + this.serverId = "dummy-server-id"; } } From 456345403665504ce5cc050d43e5c9f41df540a1 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Tue, 6 May 2025 11:08:51 -0400 Subject: [PATCH 08/23] add reference to PowerDNS OpenAPI spec --- .../dns/writer/powerdns/client/PowerDNSClient.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java index d794978a340..76bb4a39dcc 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -27,6 +27,18 @@ import okhttp3.RequestBody; import okhttp3.Response; +/** + * A client for the PowerDNS API. + * + *

This class is used to interact with the PowerDNS API. It provides methods to list servers, get + * a server, list zones, get a zone, create a zone, delete a zone, and patch a zone. Based on the PowerDNS + * API spec. + * + *

The server ID is retrieved from the server list and is used to make all subsequent requests. + * + *

The API key is retrieved from the environment variable {@code POWERDNS_API_KEY}. + */ public class PowerDNSClient { // static fields private static final FluentLogger logger = FluentLogger.forEnclosingClass(); From b9e5ff83bc14d37808d610b7f386eba1300fb6f4 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Tue, 6 May 2025 16:53:30 -0400 Subject: [PATCH 09/23] automatically create default TLD definition --- .../writer/powerdns/PowerDnsConfigModule.java | 16 +++- .../dns/writer/powerdns/PowerDnsWriter.java | 76 +++++++++++++++++-- .../powerdns/client/PowerDNSClient.java | 6 +- 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java index b9eb16d6a01..e5a1654c607 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java @@ -18,7 +18,7 @@ import dagger.Provides; import google.registry.config.RegistryConfig.Config; -/** Dagger module that provides DNS configuration settings. */ +/** Dagger module that provides PowerDNS configuration settings. */ @Module public class PowerDnsConfigModule { @@ -35,4 +35,18 @@ public static String providePowerDnsHost() { public static String providePowerDnsApiKey() { return "dummy-api-key"; } + + /** Default SOA MNAME for the TLD zone. */ + @Provides + @Config("powerDnsDefaultSoaMName") + public static String providePowerDnsDefaultSoaMName() { + return "a.gtld-servers.net."; + } + + /** Default SOA RNAME for the TLD zone. */ + @Provides + @Config("powerDnsDefaultSoaRName") + public static String providePowerDnsDefaultSoaRName() { + return "nstld.verisign-grs.com."; + } } diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index b2fe76d00a8..5efef768989 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -28,6 +28,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.concurrent.ConcurrentHashMap; import org.joda.time.Duration; import org.xbill.DNS.Record; @@ -44,6 +45,8 @@ public class PowerDnsWriter extends DnsUpdateWriter { public static final String NAME = "PowerDnsWriter"; private final String tldZoneName; + private final String powerDnsDefaultSoaMName; + private final String powerDnsDefaultSoaRName; private final PowerDNSClient powerDnsClient; private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final ConcurrentHashMap zoneIdCache = new ConcurrentHashMap<>(); @@ -67,6 +70,8 @@ public PowerDnsWriter( @Config("dnsDefaultDsTtl") Duration dnsDefaultDsTtl, @Config("powerDnsHost") String powerDnsHost, @Config("powerDnsApiKey") String powerDnsApiKey, + @Config("powerDnsDefaultSoaMName") String powerDnsDefaultSoaMName, + @Config("powerDnsDefaultSoaRName") String powerDnsDefaultSoaRName, Clock clock) { // call the DnsUpdateWriter constructor, omitting the transport parameter @@ -74,7 +79,9 @@ public PowerDnsWriter( super(tldZoneName, dnsDefaultATtl, dnsDefaultNsTtl, dnsDefaultDsTtl, null, clock); // Initialize the PowerDNS client - this.tldZoneName = tldZoneName; + this.tldZoneName = getCanonicalHostName(tldZoneName); + this.powerDnsDefaultSoaMName = powerDnsDefaultSoaMName; + this.powerDnsDefaultSoaRName = powerDnsDefaultSoaRName; this.powerDnsClient = new PowerDNSClient(powerDnsHost, powerDnsApiKey); } @@ -86,8 +93,9 @@ public PowerDnsWriter( */ @Override public void publishDomain(String domainName) { - logger.atInfo().log("Staging domain %s for PowerDNS", domainName); - super.publishDomain(domainName); + String normalizedDomainName = getCanonicalHostName(domainName); + logger.atInfo().log("Staging domain %s for PowerDNS", normalizedDomainName); + super.publishDomain(normalizedDomainName); } /** @@ -98,8 +106,9 @@ public void publishDomain(String domainName) { */ @Override public void publishHost(String hostName) { - logger.atInfo().log("Staging host %s for PowerDNS", hostName); - super.publishHost(hostName); + String normalizedHostName = getCanonicalHostName(hostName); + logger.atInfo().log("Staging host %s for PowerDNS", normalizedHostName); + super.publishHost(normalizedHostName); } @Override @@ -115,7 +124,7 @@ protected void commitUnchecked() { // call the PowerDNS API to commit the changes powerDnsClient.patchZone(zone); - } catch (IOException e) { + } catch (Exception e) { throw new RuntimeException("publishDomain failed for TLD: " + tldZoneName, e); } } @@ -193,6 +202,20 @@ private Zone convertUpdateToZone(Update update) throws IOException { return preparedTldZone; } + /** + * Returns the presentation format ending in a dot used for an given hostname. + * + * @param hostName the fully qualified hostname + */ + private String getCanonicalHostName(String hostName) { + String normalizedHostName = hostName.endsWith(".") ? hostName : hostName + '.'; + String canonicalHostName = + tldZoneName == null || normalizedHostName.endsWith(tldZoneName) + ? normalizedHostName + : normalizedHostName + tldZoneName; + return canonicalHostName.toLowerCase(Locale.US); + } + /** * Prepare the TLD zone for updates by clearing the RRSets and incrementing the serial number. * @@ -213,11 +236,50 @@ private Zone getTldZoneForUpdate(List records) { * @throws IOException if the TLD zone is not found */ private Zone getTldZoneByName() throws IOException { + // retrieve an existing TLD zone by name for (Zone zone : powerDnsClient.listZones()) { if (zone.getName().equals(tldZoneName)) { return zone; } } + + // attempt to create a new TLD zone + try { + // base TLD zone object + logger.atInfo().log("Creating new TLD zone %s", tldZoneName); + Zone newTldZone = new Zone(); + newTldZone.setName(tldZoneName); + newTldZone.setKind(Zone.ZoneKind.Native); + + // create an initial SOA record, which may be modified later by an administrator + // or an automated onboarding process + RRSet soaRecord = new RRSet(); + soaRecord.setChangeType(RRSet.ChangeType.REPLACE); + soaRecord.setName(tldZoneName); + soaRecord.setTtl(3600); + soaRecord.setType("SOA"); + + // add content to the SOA record content from default configuration + RecordObject soaRecordContent = new RecordObject(); + soaRecordContent.setContent( + String.format( + "%s %s 1 900 1800 6048000 3600", powerDnsDefaultSoaMName, powerDnsDefaultSoaRName)); + soaRecordContent.setDisabled(false); + soaRecord.setRecords(new ArrayList(Arrays.asList(soaRecordContent))); + + // add the SOA record to the new TLD zone + newTldZone.setRrsets(new ArrayList(Arrays.asList(soaRecord))); + + // create the TLD zone and log the result + Zone createdTldZone = powerDnsClient.createZone(newTldZone); + logger.atInfo().log("Successfully created TLD zone %s", tldZoneName); + return createdTldZone; + } catch (Exception e) { + // log the error and continue + logger.atWarning().log("Failed to create TLD zone %s: %s", tldZoneName, e); + } + + // otherwise, throw an exception throw new IOException("TLD zone not found: " + tldZoneName); } @@ -233,7 +295,7 @@ private String getTldZoneId() { key -> { try { return getTldZoneByName().getId(); - } catch (IOException e) { + } catch (Exception e) { // TODO: throw this exception once PowerDNS is available, but for now we are just // going to return a dummy ID logger.atWarning().log("Failed to get TLD zone ID for %s: %s", tldZoneName, e); diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java index 76bb4a39dcc..f0c74e3ba89 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -51,9 +51,13 @@ public class PowerDNSClient { private String serverId; public PowerDNSClient(String baseUrl, String apiKey) { - // initialize the base URL, API key, and HTTP client + // initialize the base URL and API key. The base URL should be of the form + // https:///api/v1. An example of a valid API call to the + // localhost to list servers is http://localhost:8081/api/v1/servers this.baseUrl = baseUrl; this.apiKey = apiKey; + + // initialize the base URL, API key, and HTTP client this.httpClient = new OkHttpClient(); this.objectMapper = new ObjectMapper(); From e3ba1ce3f77cae6fab93139992a4a5c535dfb378 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Wed, 7 May 2025 14:42:21 -0400 Subject: [PATCH 10/23] use primary zone configuration and add powerDNS resource files --- .../dns/writer/powerdns/PowerDnsWriter.java | 6 +- .../writer/powerdns/resources/management.sh | 106 ++ .../writer/powerdns/resources/openAPI.yaml | 1398 +++++++++++++++++ .../dns/writer/powerdns/resources/pdns.conf | 42 + 4 files changed, 1550 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/resources/openAPI.yaml create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/resources/pdns.conf diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 5efef768989..3a0c19ba5ad 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -243,13 +243,15 @@ private Zone getTldZoneByName() throws IOException { } } - // attempt to create a new TLD zone + // Attempt to create a new TLD zone if it does not exist. The zone will have a + // basic SOA record, but will not have DNSSEC enabled. Adding DNSSEC is a follow + // up step using pdnsutil command line tool. try { // base TLD zone object logger.atInfo().log("Creating new TLD zone %s", tldZoneName); Zone newTldZone = new Zone(); newTldZone.setName(tldZoneName); - newTldZone.setKind(Zone.ZoneKind.Native); + newTldZone.setKind(Zone.ZoneKind.Master); // create an initial SOA record, which may be modified later by an administrator // or an automated onboarding process diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh b/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh new file mode 100644 index 00000000000..cfacb716ab7 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh @@ -0,0 +1,106 @@ +## Create a new zone +# +# The following commands are used to create a new zone in PowerDNS. + +# Specify a TLD name on which to operate. In this example, the TLD is "tldtest1", +# which specifies a zone for SLDs like "mydomain.tldtest1". +ZONE=tldtest1 + +./pdnsutil create-zone $ZONE +./pdnsutil set-kind $ZONE primary +./pdnsutil add-record $ZONE SOA "a.gtld-servers.net. nstld.verisign-grs.com. 1 900 1800 6048000 3600" + +## DNSSEC Configuration +# +# The following commands are used to setup DNSSEC for a given zone. Assumes the +# user is running commands directly on the PowerDNS server. +# + +./pdnsutil add-zone-key $ZONE ksk 2048 active published rsasha256 +./pdnsutil add-zone-key $ZONE zsk 1024 active published rsasha256 +./pdnsutil set-meta $ZONE SOA-EDIT INCREMENT-WEEKS +./pdnsutil set-publish-cdnskey $ZONE +./pdnsutil rectify-zone $ZONE +./pdnsutil show-zone $ZONE + +## Example dig output after DNSSEC enabled +# +# ➜ dig +dnssec tldtest1 @127.0.0.1 +# +# ; <<>> DiG 9.10.6 <<>> +dnssec tldtest1 @127.0.0.1 +# ;; global options: +cmd +# ;; Got answer: +# ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 20034 +# ;; flags: qr aa rd; QUERY: 1, ANSWER: 0, AUTHORITY: 4, ADDITIONAL: 1 +# ;; WARNING: recursion requested but not available +# +# ;; OPT PSEUDOSECTION: +# ; EDNS: version: 0, flags: do; udp: 1232 +# ;; QUESTION SECTION: +# ;tldtest1. IN A +# +# ;; AUTHORITY SECTION: +# tldtest1. 3600 IN SOA a.gtld-servers.net. nstld.verisign-grs.com. 2025053493 10800 3600 604800 3600 +# tldtest1. 3600 IN RRSIG SOA 8 1 3600 20250515000000 20250424000000 14128 tldtest1. I8E5RB2yADcYJIORd6ZwgBKRiNW7kMcZqO/xA6gTHbOY/SVLgib6wVe5 ohEk6n7VFTHQKz+Yv1VV5yIwI+ctGE2er3lW/r+jPMZ0AGduTUF59s94 Bsz/5Zzzq6gZZTscOtezOBiKjO4V42h99hQxA4x3jIKYs/rO+ijaNmy4 c7A= +# tldtest1. 3600 IN NSEC domain1.tldtest1. SOA RRSIG NSEC DNSKEY CDNSKEY +# tldtest1. 3600 IN RRSIG NSEC 8 1 3600 20250515000000 20250424000000 14128 tldtest1. eld/e7tESam0faZJMyJRR8ldMqPmAkOOnhz3sLUUpfmY6KJKwQWVIBn1 xs0jMhmOXqzFWcukEGT9tDUUGlA5RyhY+ihorwMntu18sHewTKWjTFeb VyNkfze4nExBzCfrgbyl6O9W//68QDKIfB29yf1rOrPszdOwhR90Ko0o BzQ= +# +# ;; Query time: 4 msec +# ;; SERVER: 127.0.0.1#53(127.0.0.1) +# ;; WHEN: Wed May 07 09:42:22 EDT 2025 +# ;; MSG SIZE rcvd: 489 + +## DNSSEC ZSK key rotation (monthly) +# +# The following commands are used to rotate the ZSK for a given zone. +# https://doc.powerdns.com/authoritative/guides/zskroll.html + +# Create an INACTIVE/PUBLISHED key that can begin propagating +./pdnsutil add-zone-key $ZONE zsk 1024 inactive published rsasha256 + +# Show the settings and make note of the key IDs for both the old and +# new ZSKs to be used later. +./pdnsutil show-zone $ZONE + +# Validate with dig that the DNSKEY is available, making note of the TTL +# for this record. Need to wait for both old/new DNSKEY records to be shown +# on secondary DNS servers before proceeding. +dig DNSKEY $ZONE @127.0.0.1 + +# Change activation status for the two ZSKs. The key IDs are found in the +# previous output from show-zone. At this point, the new ZSK will be used +# for signing, but the old key is still available for verification. +./pdnsutil activate-zone-key $ZONE NEW-ZSK-ID +./pdnsutil deactivate-zone-key $ZONE OLD-ZSK-ID + +# Wait for the longest TTL to ensure all secondaries have latest values, +# and then remove the old key. +./pdnsutil remove-zone-key $ZONE OLD-ZSK-ID + +## DNSSEC KSK key rotation (annually) +# +# The following commands are used to rotate the KSK for a given zone. +# https://doc.powerdns.com/authoritative/guides/kskroll.html + +# Create an ACTIVE/PUBLISHED key that can begin propagating +./pdnsutil add-zone-key $ZONE ksk 2048 active published rsasha256 + +# Show the settings and make note of the key IDs for both the old and +# new KSKs to be used later. +./pdnsutil show-zone $ZONE + +# Validate with dig that the DNSKEY is available, making note of the TTL +# for this record. Need to wait for both old/new DNSKEY records to be shown +# on secondary DNS servers before proceeding. +dig DNSKEY $ZONE @127.0.0.1 + +# Now that both old/new KSK values are being used to sign (both are active), +# we can communicate with the gTLD registry to give them a new DS value to +# publish in the parent DNS zone. The output of DS or DNSKEY for the new KSK +# can be provided to the registry, depending on what format they need. Some +# registries may handle this automatically by reading our DNSKEY records, but +# need to verify on a per-registry basis if this is the case. +./pdnsutil show-zone $ZONE + +# After propagation, the old key may be removed. +./pdnsutil remove-zone-key $ZONE OLD-KSK-ID diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/resources/openAPI.yaml b/core/src/main/java/google/registry/dns/writer/powerdns/resources/openAPI.yaml new file mode 100644 index 00000000000..555fe70c2b7 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/resources/openAPI.yaml @@ -0,0 +1,1398 @@ +swagger: '2.0' +info: + version: "0.0.15" + title: PowerDNS Authoritative HTTP API + license: + name: MIT +basePath: /api/v1 +consumes: + - application/json +produces: + - application/json +securityDefinitions: + # X-API-Key: abcdef12345 + APIKeyHeader: + type: apiKey + in: header + name: X-API-Key +security: + - APIKeyHeader: [] + +# Overall TODOS: +# TODO: Return types are not consistent across documentation +# We need to look at the code and figure out the default HTTP response +# codes and adjust docs accordingly. +paths: + '/error': + get: + summary: Will always generate an error + operationId: error + responses: &commonErrors + '400': + description: The supplied request was not valid + schema: + $ref: '#/definitions/Error' + '404': + description: Requested item was not found + schema: + $ref: '#/definitions/Error' + '422': + description: The input to the operation was not valid + schema: + $ref: '#/definitions/Error' + '500': + description: Internal server error + schema: + $ref: '#/definitions/Error' + + '/servers': + get: + summary: List all servers + operationId: listServers + tags: + - servers + responses: + '200': + description: An array of servers + schema: + type: array + items: + $ref: '#/definitions/Server' + <<: *commonErrors + + '/servers/{server_id}': + get: + summary: List a server + operationId: listServer + tags: + - servers + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + responses: + '200': + description: An server + schema: + $ref: '#/definitions/Server' + <<: *commonErrors + + '/servers/{server_id}/cache/flush': + put: + summary: Flush a cache-entry by name + operationId: cacheFlushByName + tags: + - servers + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: domain + in: query + required: true + description: The domain name to flush from the cache + type: string + responses: + '200': + description: Flush successful + schema: + $ref: '#/definitions/CacheFlushResult' + <<: *commonErrors + + '/servers/{server_id}/zones': + get: + summary: List all Zones in a server + operationId: listZones + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone + in: query + required: false + type: string + description: | + When set to the name of a zone, only this zone is returned. + If no zone with that name exists, the response is an empty array. + This can e.g. be used to check if a zone exists in the database without having to guess/encode the zone's id or to check if a zone exists. + - name: dnssec + in: query + required: false + type: boolean + default: true + description: '“true” (default) or “false”, whether to include the “dnssec” and ”edited_serial” fields in the Zone objects. Setting this to ”false” will make the query a lot faster.' + responses: + '200': + description: An array of Zones + schema: + type: array + items: + $ref: '#/definitions/Zone' + <<: *commonErrors + post: + summary: Creates a new domain, returns the Zone on creation. + operationId: createZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: rrsets + in: query + description: '“true” (default) or “false”, whether to include the “rrsets” in the response Zone object.' + type: boolean + default: true + - name: zone_struct + description: The zone struct to patch with + required: true + in: body + schema: + $ref: '#/definitions/Zone' + responses: + '201': + description: A zone + schema: + $ref: '#/definitions/Zone' + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}': + get: + summary: zone managed by a server + operationId: listZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + - name: rrsets + in: query + description: '“true” (default) or “false”, whether to include the “rrsets” in the response Zone object.' + type: boolean + default: true + - name: rrset_name + in: query + description: Limit output to RRsets for this name. + type: string + - name: rrset_type + in: query + description: Limit output to the RRset of this type. Can only be used together with rrset_name. + type: string + - name: include_disabled + in: query + description: '“true” (default) or “false”, whether to include disabled RRsets in the response.' + type: boolean + responses: + '200': + description: A Zone + schema: + $ref: '#/definitions/Zone' + <<: *commonErrors + delete: + summary: Deletes this zone, all attached metadata and rrsets. + operationId: deleteZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '204': + description: 'Returns 204 No Content on success.' + <<: *commonErrors + patch: + summary: 'Creates/modifies/deletes RRsets present in the payload and their comments. Returns 204 No Content on success.' + operationId: patchZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: zone_struct + description: The zone struct to patch with + required: true + in: body + schema: + $ref: '#/definitions/Zone' + responses: + '204': + description: 'Returns 204 No Content on success.' + <<: *commonErrors + + put: + summary: Modifies basic zone data. + description: 'The only fields in the zone structure which can be modified are: kind, masters, catalog, account, soa_edit, soa_edit_api, api_rectify, dnssec, and nsec3param. All other fields are ignored.' + operationId: putZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: zone_struct + description: The zone struct to patch with + required: true + in: body + schema: + $ref: '#/definitions/Zone' + responses: + '204': + description: 'Returns 204 No Content on success.' + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/notify': + put: + summary: Send a DNS NOTIFY to all slaves. + description: 'Fails when zone kind is not Master or Slave, or master and slave are disabled in the configuration. Only works for Slave if renotify is on. Clients MUST NOT send a body.' + operationId: notifyZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: OK + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/axfr-retrieve': + put: + summary: Retrieve slave zone from its master. + description: 'Fails when zone kind is not Slave, or slave is disabled in the configuration. Clients MUST NOT send a body.' + operationId: axfrRetrieveZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: OK + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/export': + get: + summary: 'Returns the zone in AXFR format.' + operationId: axfrExportZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: OK + schema: + type: string + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/rectify': + put: + summary: 'Rectify the zone data.' + description: 'This does not take into account the API-RECTIFY metadata. Fails on slave zones and zones that do not have DNSSEC.' + operationId: rectifyZone + tags: + - zones + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: OK + schema: + type: string + <<: *commonErrors + + '/servers/{server_id}/config': + get: + summary: 'Returns all ConfigSettings for a single server' + operationId: getConfig + tags: + - config + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + responses: + '200': + description: List of config values + schema: + type: array + items: + $ref: '#/definitions/ConfigSetting' + <<: *commonErrors + + '/servers/{server_id}/config/{config_setting_name}': + get: + summary: 'Returns a specific ConfigSetting for a single server' + description: 'NOT IMPLEMENTED' + operationId: getConfigSetting + tags: + - config + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: config_setting_name + in: path + required: true + description: The name of the setting to retrieve + type: string + responses: + '200': + description: List of config values + schema: + $ref: '#/definitions/ConfigSetting' + <<: *commonErrors + + '/servers/{server_id}/statistics': + get: + summary: 'Query statistics.' + description: 'Query PowerDNS internal statistics.' + operationId: getStats + tags: + - stats + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: statistic + in: query + required: false + type: string + description: | + When set to the name of a specific statistic, only this value is returned. + If no statistic with that name exists, the response has a 422 status and an error message. + - name: includerings + in: query + required: false + type: boolean + default: true + description: '“true” (default) or “false”, whether to include the Ring items, which can contain thousands of log messages or queried domains. Setting this to ”false” may make the response a lot smaller.' + responses: + '200': + description: List of Statistic Items + schema: + type: array + items: + - $ref: '#/definitions/StatisticItem' + - $ref: '#/definitions/MapStatisticItem' + - $ref: '#/definitions/RingStatisticItem' + '422': + description: 'Returned when a non-existing statistic name has been requested. Contains an error message' + <<: *commonErrors + + '/servers/{server_id}/search-data': + get: + summary: 'Search the data inside PowerDNS' + description: 'Search the data inside PowerDNS for search_term and return at most max_results. This includes zones, records and comments. The * character can be used in search_term as a wildcard character and the ? character can be used as a wildcard for a single character.' + operationId: searchData + tags: + - search + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: q + in: query + required: true + description: 'The string to search for' + type: string + - name: max + in: query + required: true + description: 'Maximum number of entries to return' + type: integer + - name: object_type + in: query + required: false + description: 'Type of data to search for, one of “all”, “zone”, “record”, “comment”' + type: string + responses: + '200': + description: Returns a JSON array with results + schema: + $ref: '#/definitions/SearchResults' + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/metadata': + get: + summary: 'Get all the Metadata associated with the zone.' + operationId: listMetadata + tags: + - zonemetadata + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: List of Metadata objects + schema: + type: array + items: + $ref: '#/definitions/Metadata' + <<: *commonErrors + post: + summary: 'Creates a set of metadata entries' + description: 'Creates a set of metadata entries of given kind for the zone. Existing metadata entries for the zone with the same kind are not overwritten.' + operationId: createMetadata + tags: + - zonemetadata + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: metadata + description: Metadata object with list of values to create + required: true + in: body + schema: + $ref: '#/definitions/Metadata' + responses: + '204': + description: OK + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/metadata/{metadata_kind}': + get: + summary: 'Get the content of a single kind of domain metadata as a Metadata object.' + operationId: getMetadata + tags: + - zonemetadata + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + - name: metadata_kind + type: string + in: path + required: true + description: The kind of metadata + responses: + '200': + description: Metadata object with list of values + schema: + $ref: '#/definitions/Metadata' + <<: *commonErrors + put: + summary: 'Replace the content of a single kind of domain metadata.' + description: 'Creates a set of metadata entries of given kind for the zone. Existing metadata entries for the zone with the same kind are removed.' + operationId: modifyMetadata + tags: + - zonemetadata + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: metadata_kind + description: The kind of metadata + required: true + type: string + in: path + - name: metadata + description: metadata to add/create + required: true + in: body + schema: + $ref: '#/definitions/Metadata' + responses: + '200': + description: Metadata object with list of values + schema: + $ref: '#/definitions/Metadata' + <<: *commonErrors + delete: + summary: 'Delete all items of a single kind of domain metadata.' + operationId: deleteMetadata + tags: + - zonemetadata + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + - name: metadata_kind + type: string + in: path + required: true + description: The kind of metadata + responses: + '204': + description: OK + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/cryptokeys': + get: + summary: 'Get all CryptoKeys for a zone, except the privatekey' + operationId: listCryptokeys + tags: + - zonecryptokey + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + responses: + '200': + description: List of Cryptokey objects + schema: + type: array + items: + $ref: '#/definitions/Cryptokey' + <<: *commonErrors + post: + summary: 'Creates a Cryptokey' + description: 'This method adds a new key to a zone. The key can either be generated or imported by supplying the content parameter. if content, bits and algo are null, a key will be generated based on the default-ksk-algorithm and default-ksk-size settings for a KSK and the default-zsk-algorithm and default-zsk-size options for a ZSK.' + operationId: createCryptokey + tags: + - zonecryptokey + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: cryptokey + description: Add a Cryptokey + required: true + in: body + schema: + $ref: '#/definitions/Cryptokey' + responses: + '201': + description: Created + schema: + $ref: '#/definitions/Cryptokey' + <<: *commonErrors + + '/servers/{server_id}/zones/{zone_id}/cryptokeys/{cryptokey_id}': + get: + summary: 'Returns all data about the CryptoKey, including the privatekey.' + operationId: getCryptokey + tags: + - zonecryptokey + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + - name: cryptokey_id + type: string + in: path + required: true + description: 'The id value of the CryptoKey' + responses: + '200': + description: Cryptokey + schema: + $ref: '#/definitions/Cryptokey' + <<: *commonErrors + put: + summary: 'This method (de)activates a key from zone_name specified by cryptokey_id' + operationId: modifyCryptokey + tags: + - zonecryptokey + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + - name: cryptokey_id + description: Cryptokey to manipulate + required: true + in: path + type: string + - name: cryptokey + description: the Cryptokey + required: true + in: body + schema: + $ref: '#/definitions/Cryptokey' + responses: + '204': + description: OK + <<: *commonErrors + delete: + summary: 'This method deletes a key specified by cryptokey_id.' + operationId: deleteCryptokey + tags: + - zonecryptokey + parameters: + - name: server_id + in: path + required: true + description: The id of the server to retrieve + type: string + - name: zone_id + type: string + in: path + required: true + description: The id of the zone to retrieve + - name: cryptokey_id + type: string + in: path + required: true + description: 'The id value of the Cryptokey' + responses: + '204': + description: OK + <<: *commonErrors + + '/servers/{server_id}/tsigkeys': + parameters: + - name: server_id + in: path + required: true + description: 'The id of the server' + type: string + get: + summary: 'Get all TSIGKeys on the server, except the actual key' + operationId: listTSIGKeys + tags: + - tsigkey + responses: + '200': + description: List of TSIGKey objects + schema: + type: array + items: + $ref: '#/definitions/TSIGKey' + <<: *commonErrors + post: + summary: 'Add a TSIG key' + description: 'This methods add a new TSIGKey. The actual key can be generated by the server or be provided by the client' + operationId: createTSIGKey + tags: + - tsigkey + parameters: + - name: tsigkey + description: The TSIGKey to add + required: true + in: body + schema: + $ref: '#/definitions/TSIGKey' + responses: + '201': + description: Created + schema: + $ref: '#/definitions/TSIGKey' + '409': + description: An item with this name already exists + schema: + $ref: '#/definitions/Error' + <<: *commonErrors + + '/servers/{server_id}/tsigkeys/{tsigkey_id}': + parameters: + - name: server_id + in: path + required: true + description: 'The id of the server to retrieve the key from' + type: string + - name: tsigkey_id + in: path + required: true + description: 'The id of the TSIGkey. Should match the "id" field in the TSIGKey object' + type: string + get: + summary: 'Get a specific TSIGKeys on the server, including the actual key' + operationId: getTSIGKey + tags: + - tsigkey + responses: + '200': + description: OK. + schema: + $ref: '#/definitions/TSIGKey' + <<: *commonErrors + put: + description: | + The TSIGKey at tsigkey_id can be changed in multiple ways: + * Changing the Name, this will remove the key with tsigkey_id after adding. + * Changing the Algorithm + * Changing the Key + + Only the relevant fields have to be provided in the request body. + operationId: putTSIGKey + tags: + - tsigkey + parameters: + - name: tsigkey + description: A (possibly stripped down) TSIGKey object with the new values + schema: + $ref: '#/definitions/TSIGKey' + in: body + required: true + responses: + '200': + description: OK. TSIGKey is changed. + schema: + $ref: '#/definitions/TSIGKey' + '409': + description: An item with this name already exists + schema: + $ref: '#/definitions/Error' + <<: *commonErrors + delete: + summary: 'Delete the TSIGKey with tsigkey_id' + operationId: deleteTSIGKey + tags: + - tsigkey + responses: + '204': + description: 'OK, key was deleted' + <<: *commonErrors + + '/servers/{server_id}/autoprimaries': + parameters: + - name: server_id + in: path + required: true + description: 'The id of the server to manage the list of autoprimaries on' + type: string + get: + summary: 'Get a list of autoprimaries' + operationId: getAutoprimaries + tags: + - autoprimary + responses: + '200': + description: OK. + schema: + $ref: '#/definitions/Autoprimary' + <<: *commonErrors + post: + summary: 'Add an autoprimary' + description: 'This methods add a new autoprimary server.' + operationId: createAutoprimary + tags: + - autoprimary + parameters: + - name: autoprimary + description: autoprimary entry to add + required: true + in: body + schema: + $ref: '#/definitions/Autoprimary' + responses: + '201': + description: Created + <<: *commonErrors + + '/servers/{server_id}/autoprimaries/{ip}/{nameserver}': + parameters: + - name: server_id + in: path + required: true + description: 'The id of the server to delete the autoprimary from' + type: string + - name: ip + in: path + required: true + description: 'IP address of autoprimary' + type: string + - name: nameserver + in: path + required: true + description: 'DNS name of the autoprimary' + type: string + delete: + summary: 'Delete the autoprimary entry' + operationId: deleteAutoprimary + tags: + - autoprimary + responses: + '204': + description: 'OK, key was deleted' + <<: *commonErrors + +definitions: + Server: + title: Server + properties: + type: + type: string + description: 'Set to “Server”' + id: + type: string + description: 'The id of the server, “localhost”' + daemon_type: + type: string + description: '“recursor” for the PowerDNS Recursor and “authoritative” for the Authoritative Server' + version: + type: string + description: 'The version of the server software' + url: + type: string + description: 'The API endpoint for this server' + config_url: + type: string + description: 'The API endpoint for this server’s configuration' + zones_url: + type: string + description: 'The API endpoint for this server’s zones' + + Servers: + type: array + items: + $ref: '#/definitions/Server' + + Zone: + title: Zone + description: This represents an authoritative DNS Zone. + properties: + id: + type: string + description: 'Opaque zone id (string), assigned by the server, should not be interpreted by the application. Guaranteed to be safe for embedding in URLs.' + name: + type: string + description: 'Name of the zone (e.g. “example.com.”) MUST have a trailing dot' + type: + type: string + description: 'Set to “Zone”' + url: + type: string + description: 'API endpoint for this zone' + kind: + type: string + enum: + - 'Native' + - 'Master' + - 'Slave' + - 'Producer' + - 'Consumer' + description: 'Zone kind, one of “Native”, “Master”, “Slave”, “Producer”, “Consumer”' + rrsets: + type: array + items: + $ref: '#/definitions/RRSet' + description: 'RRSets in this zone (for zones/{zone_id} endpoint only; omitted during GET on the .../zones list endpoint)' + serial: + type: integer + description: 'The SOA serial number' + notified_serial: + type: integer + description: 'The SOA serial notifications have been sent out for' + edited_serial: + type: integer + description: 'The SOA serial as seen in query responses. Calculated using the SOA-EDIT metadata, default-soa-edit and default-soa-edit-signed settings' + masters: + type: array + items: + type: string + description: ' List of IP addresses configured as a master for this zone (“Slave” type zones only)' + dnssec: + type: boolean + description: 'Whether or not this zone is DNSSEC signed (inferred from presigned being true XOR presence of at least one cryptokey with active being true)' + nsec3param: + type: string + description: 'The NSEC3PARAM record' + nsec3narrow: + type: boolean + description: 'Whether or not the zone uses NSEC3 narrow' + presigned: + type: boolean + description: 'Whether or not the zone is pre-signed' + soa_edit: + type: string + description: 'The SOA-EDIT metadata item' + soa_edit_api: + type: string + description: 'The SOA-EDIT-API metadata item' + api_rectify: + type: boolean + description: 'Whether or not the zone will be rectified on data changes via the API' + zone: + type: string + description: 'MAY contain a BIND-style zone file when creating a zone' + catalog: + type: string + description: 'The catalog this zone is a member of' + account: + type: string + description: 'MAY be set. Its value is defined by local policy' + nameservers: + type: array + items: + type: string + description: 'MAY be sent in client bodies during creation, and MUST NOT be sent by the server. Simple list of strings of nameserver names, including the trailing dot. Not required for slave zones.' + master_tsig_key_ids: + type: array + items: + type: string + description: 'The id of the TSIG keys used for master operation in this zone' + externalDocs: + url: 'https://doc.powerdns.com/authoritative/tsig.html#provisioning-outbound-axfr-access' + slave_tsig_key_ids: + type: array + items: + type: string + description: 'The id of the TSIG keys used for slave operation in this zone' + externalDocs: + url: 'https://doc.powerdns.com/authoritative/tsig.html#provisioning-signed-notification-and-axfr-requests' + + Zones: + type: array + items: + $ref: '#/definitions/Zone' + + RRSet: + title: RRSet + description: This represents a Resource Record Set (all records with the same name and type). + required: + - name + - type + - ttl + - changetype + - records + properties: + name: + type: string + description: 'Name for record set (e.g. “www.powerdns.com.”)' + type: + type: string + description: 'Type of this record (e.g. “A”, “PTR”, “MX”)' + ttl: + type: integer + description: 'DNS TTL of the records, in seconds. MUST NOT be included when changetype is set to “DELETE”.' + changetype: + type: string + description: 'MUST be added when updating the RRSet. Must be REPLACE or DELETE. With DELETE, all existing RRs matching name and type will be deleted, including all comments. With REPLACE: when records is present, all existing RRs matching name and type will be deleted, and then new records given in records will be created. If no records are left, any existing comments will be deleted as well. When comments is present, all existing comments for the RRs matching name and type will be deleted, and then new comments given in comments will be created.' + records: + type: array + description: 'All records in this RRSet. When updating Records, this is the list of new records (replacing the old ones). Must be empty when changetype is set to DELETE. An empty list results in deletion of all records (and comments).' + items: + $ref: '#/definitions/Record' + comments: + type: array + description: 'List of Comment. Must be empty when changetype is set to DELETE. An empty list results in deletion of all comments. modified_at is optional and defaults to the current server time.' + items: + $ref: '#/definitions/Comment' + + Record: + title: Record + description: The RREntry object represents a single record. + required: + - content + properties: + content: + type: string + description: 'The content of this record' + disabled: + type: boolean + description: 'Whether or not this record is disabled. When unset, the record is not disabled' + + Comment: + title: Comment + description: A comment about an RRSet. + properties: + content: + type: string + description: 'The actual comment' + account: + type: string + description: 'Name of an account that added the comment' + modified_at: + type: integer + description: 'Timestamp of the last change to the comment' + + TSIGKey: + title: TSIGKey + description: A TSIG key that can be used to authenticate NOTIFY, AXFR, and DNSUPDATE queries. + properties: + name: + type: string + description: 'The name of the key' + id: + type: string + description: 'The ID for this key, used in the TSIGkey URL endpoint.' + readOnly: true + algorithm: + type: string + description: 'The algorithm of the TSIG key' + key: + type: string + description: 'The Base64 encoded secret key, empty when listing keys. MAY be empty when POSTing to have the server generate the key material' + type: + type: string + description: 'Set to "TSIGKey"' + readOnly: true + + Autoprimary: + title: Autoprimary server + description: An autoprimary server that can provision new domains. + properties: + ip: + type: string + description: "IP address of the autoprimary server" + nameserver: + type: string + description: "DNS name of the autoprimary server" + account: + type: string + description: "Account name for the autoprimary server" + + ConfigSetting: + title: ConfigSetting + properties: + name: + type: string + description: 'set to "ConfigSetting"' + type: + type: string + description: 'The name of this setting (e.g. ‘webserver-port’)' + value: + type: string + description: 'The value of setting name' + + SimpleStatisticItem: + title: SimpleStatisticItem + type: object + properties: + name: + type: string + description: 'Item name' + value: + type: string + description: 'Item value' + + StatisticItem: + title: StatisticItem + properties: + name: + type: string + description: 'Item name' + type: + type: string + description: 'set to "StatisticItem"' + value: + type: string + description: 'Item value' + + MapStatisticItem: + title: MapStatisticItem + properties: + name: + type: string + description: 'Item name' + type: + type: string + description: 'Set to "MapStatisticItem"' + value: + type: array + description: 'Named values' + items: + $ref: '#/definitions/SimpleStatisticItem' + + RingStatisticItem: + title: RingStatisticItem + properties: + name: + type: string + description: 'Item name' + type: + type: string + description: 'Set to "RingStatisticItem"' + size: + type: integer + description: 'Ring size' + value: + type: array + description: 'Named values' + items: + $ref: '#/definitions/SimpleStatisticItem' + + SearchResultZone: + title: SearchResultZone + properties: + name: + type: string + object_type: + type: string + description: 'set to "zone"' + zone_id: + type: string + + SearchResultRecord: + title: SearchResultRecord + properties: + content: + type: string + disabled: + type: boolean + name: + type: string + object_type: + type: string + description: 'set to "record"' + zone_id: + type: string + zone: + type: string + type: + type: string + ttl: + type: integer + + SearchResultComment: + title: SearchResultComment + properties: + content: + type: string + name: + type: string + object_type: + type: string + description: 'set to "comment"' + zone_id: + type: string + zone: + type: string + +# FIXME: This is problematic at the moment, because swagger doesn't support this type of mixed response +# SearchResult: +# anyOf: +# - $ref: '#/definitions/SearchResultZone' +# - $ref: '#/definitions/SearchResultRecord' +# - $ref: '#/definitions/SearchResultComment' + +# Since we can't do 'anyOf' at the moment, we create a 'superset object' + SearchResult: + title: SearchResult + properties: + content: + type: string + disabled: + type: boolean + name: + type: string + object_type: + type: string + description: 'set to one of "record, zone, comment"' + zone_id: + type: string + zone: + type: string + type: + type: string + ttl: + type: integer + + SearchResults: + type: array + items: + $ref: '#/definitions/SearchResult' + + Metadata: + title: Metadata + description: Represents zone metadata + properties: + kind: + type: string + description: 'Name of the metadata' + metadata: + type: array + items: + type: string + description: 'Array with all values for this metadata kind.' + + Cryptokey: + title: Cryptokey + description: 'Describes a DNSSEC cryptographic key' + properties: + type: + type: string + description: 'set to "Cryptokey"' + id: + type: integer + description: 'The internal identifier, read only' + keytype: + type: string + enum: [ksk, zsk, csk] + active: + type: boolean + description: 'Whether or not the key is in active use' + published: + type: boolean + description: 'Whether or not the DNSKEY record is published in the zone' + dnskey: + type: string + description: 'The DNSKEY record for this key' + ds: + type: array + items: + type: string + description: 'An array of DS records for this key' + cds: + type: array + items: + type: string + description: 'An array of DS records for this key, filtered by CDS publication settings' + privatekey: + type: string + description: 'The private key in ISC format' + algorithm: + type: string + description: 'The name of the algorithm of the key, should be a mnemonic' + bits: + type: integer + description: 'The size of the key' + + Error: + title: Error + description: 'Returned when the server encounters an error, either in client input or internally' + properties: + error: + type: string + description: 'A human readable error message' + errors: + type: array + items: + type: string + description: 'Optional array of multiple errors encountered during processing' + required: + - error + + CacheFlushResult: + title: CacheFlushResult + description: 'The result of a cache-flush' + properties: + count: + type: number + description: 'Amount of entries flushed' + result: + type: string + description: 'A message about the result like "Flushed cache"' \ No newline at end of file diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/resources/pdns.conf b/core/src/main/java/google/registry/dns/writer/powerdns/resources/pdns.conf new file mode 100644 index 00000000000..a2fb0d169e4 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/resources/pdns.conf @@ -0,0 +1,42 @@ +# Minimal PowerDNS config to support DNSSEC using a PostgreSQL backend. A +# production ready configuration would need to customize IP addresses, ports, +# and keys, in addition to any other settings. + +################################# +# PROCESS CONFIG +# +guardian=yes +launch=gpgsql +local-address=127.0.0.1 +local-port=53 + +################################# +# BASE OPERATING MODE CONFIG +# +primary=yes +secondary=no + +################################# +# WEB SERVER CONFIG +# +webserver=yes +webserver-address=127.0.0.1 +webserver-allow-from=127.0.0.1,::1 +webserver-password=dummy-website-password +webserver-port=8081 + +################################# +# API CONFIG +# +api=yes +api-key=dummy-api-key + +################################# +# POSTGRES CONNECTION INFO +# +gpgsql-host=localhost +gpgsql-port=5432 +gpgsql-dbname=powerdns-dev +gpgsql-user=postgres +gpgsql-password= +gpgsql-dnssec=yes From 45d2cf756534de159488587e8aabd396441d0317 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Thu, 8 May 2025 13:50:42 -0400 Subject: [PATCH 11/23] add zone xfer settings --- .../registry/dns/writer/powerdns/resources/pdns.conf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/resources/pdns.conf b/core/src/main/java/google/registry/dns/writer/powerdns/resources/pdns.conf index a2fb0d169e4..1980c0b34d2 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/resources/pdns.conf +++ b/core/src/main/java/google/registry/dns/writer/powerdns/resources/pdns.conf @@ -10,6 +10,14 @@ launch=gpgsql local-address=127.0.0.1 local-port=53 +################################ +# ZONE TRANSFER CONFIG +# +allow-axfr-ips=127.0.0.0/8,::1 +disable-axfr=no +disable-axfr-rectify=no +also-notify=127.0.0.1:54 + ################################# # BASE OPERATING MODE CONFIG # From 38ae70a3df1ba09cca270f6db47eeeaf221f9073 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Tue, 13 May 2025 12:12:31 -0400 Subject: [PATCH 12/23] PowerDNS client updates for dynamic config --- .../registry/config/RegistryConfig.java | 40 +++++++++++ .../config/RegistryConfigSettings.java | 9 +++ .../registry/config/files/default-config.yaml | 6 ++ .../writer/powerdns/PowerDnsConfigModule.java | 52 -------------- .../dns/writer/powerdns/PowerDnsWriter.java | 69 +++++++++---------- .../powerdns/client/PowerDNSClient.java | 63 ++++++++++------- .../writer/powerdns/client/model/Comment.java | 2 + .../writer/powerdns/client/model/RRSet.java | 2 + .../powerdns/client/model/RecordObject.java | 2 + .../writer/powerdns/client/model/Server.java | 2 + .../writer/powerdns/client/model/Zone.java | 5 +- .../registry/module/RequestComponent.java | 2 - .../backend/BackendRequestComponent.java | 2 - .../registry/tools/RegistryToolComponent.java | 2 - 14 files changed, 138 insertions(+), 120 deletions(-) delete mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index f5421975d10..1ca12b52fca 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -130,6 +130,46 @@ public static String provideLocationId(RegistryConfigSettings config) { return config.gcpProject.locationId; } + /** Base URL of the PowerDNS server. */ + @Provides + @Config("powerDnsBaseUrl") + public static String providePowerDnsBaseUrl(RegistryConfigSettings config) { + if (config.powerDns != null && config.powerDns.baseUrl != null) { + return config.powerDns.baseUrl; + } + return "http://localhost:8081/api/v1"; + } + + /** API key for the PowerDNS server. */ + @Provides + @Config("powerDnsApiKey") + public static String providePowerDnsApiKey(RegistryConfigSettings config) { + if (config.powerDns != null && config.powerDns.apiKey != null) { + return config.powerDns.apiKey; + } + return "dummy-api-key"; + } + + /** Default SOA MNAME for the TLD zone. */ + @Provides + @Config("powerDnsDefaultSoaMName") + public static String providePowerDnsDefaultSoaMName(RegistryConfigSettings config) { + if (config.powerDns != null && config.powerDns.defaultSoaMName != null) { + return config.powerDns.defaultSoaMName; + } + return "a.gtld-servers.net."; + } + + /** Default SOA RNAME for the TLD zone. */ + @Provides + @Config("powerDnsDefaultSoaRName") + public static String providePowerDnsDefaultSoaRName(RegistryConfigSettings config) { + if (config.powerDns != null && config.powerDns.defaultSoaRName != null) { + return config.powerDns.defaultSoaRName; + } + return "nstld.verisign-grs.com."; + } + /** * The product name of this specific registry. Used throughout the registrar console. * diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 32dd08ee84d..b912c268f0f 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -43,6 +43,7 @@ public class RegistryConfigSettings { public DnsUpdate dnsUpdate; public BulkPricingPackageMonitoring bulkPricingPackageMonitoring; public Bsa bsa; + public PowerDns powerDns; /** Configuration options that apply to the entire GCP project. */ public static class GcpProject { @@ -58,6 +59,14 @@ public static class GcpProject { public String baseDomain; } + /** Configuration options for PowerDNS. */ + public static class PowerDns { + public String baseUrl; + public String apiKey; + public String defaultSoaMName; + public String defaultSoaRName; + } + /** Configuration options for authenticating users. */ public static class Auth { public List allowedServiceAccountEmails; diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 16f5bbd63c9..c580b53132f 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -27,6 +27,12 @@ gcpProject: # The base domain name of the registry service. Services are reachable at [service].baseDomain. baseDomain: registry.test +powerDns: + baseUrl: http://localhost:8081/api/v1 + apiKey: example-api-key + defaultSoaMName: a.example.com. + defaultSoaRName: nstld.example.com. + gSuite: # Publicly accessible domain name of the running G Suite instance. domainName: domain-registry.example diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java deleted file mode 100644 index e5a1654c607..00000000000 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigModule.java +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright 2025 The Nomulus Authors. All Rights Reserved. -// -// Licensed 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 google.registry.dns.writer.powerdns; - -import dagger.Module; -import dagger.Provides; -import google.registry.config.RegistryConfig.Config; - -/** Dagger module that provides PowerDNS configuration settings. */ -@Module -public class PowerDnsConfigModule { - - /** Host of the PowerDNS server. */ - @Provides - @Config("powerDnsHost") - public static String providePowerDnsHost() { - return "localhost"; - } - - /** API key for the PowerDNS server. */ - @Provides - @Config("powerDnsApiKey") - public static String providePowerDnsApiKey() { - return "dummy-api-key"; - } - - /** Default SOA MNAME for the TLD zone. */ - @Provides - @Config("powerDnsDefaultSoaMName") - public static String providePowerDnsDefaultSoaMName() { - return "a.gtld-servers.net."; - } - - /** Default SOA RNAME for the TLD zone. */ - @Provides - @Config("powerDnsDefaultSoaRName") - public static String providePowerDnsDefaultSoaRName() { - return "nstld.verisign-grs.com."; - } -} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 3a0c19ba5ad..2b9666a220d 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -58,7 +58,7 @@ public class PowerDnsWriter extends DnsUpdateWriter { * @param dnsDefaultATtl the default TTL for A records * @param dnsDefaultNsTtl the default TTL for NS records * @param dnsDefaultDsTtl the default TTL for DS records - * @param powerDnsHost the host of the PowerDNS server + * @param powerDnsBaseUrl the base URL of the PowerDNS server * @param powerDnsApiKey the API key for the PowerDNS server * @param clock the clock to use for the PowerDNS writer */ @@ -68,7 +68,7 @@ public PowerDnsWriter( @Config("dnsDefaultATtl") Duration dnsDefaultATtl, @Config("dnsDefaultNsTtl") Duration dnsDefaultNsTtl, @Config("dnsDefaultDsTtl") Duration dnsDefaultDsTtl, - @Config("powerDnsHost") String powerDnsHost, + @Config("powerDnsBaseUrl") String powerDnsBaseUrl, @Config("powerDnsApiKey") String powerDnsApiKey, @Config("powerDnsDefaultSoaMName") String powerDnsDefaultSoaMName, @Config("powerDnsDefaultSoaRName") String powerDnsDefaultSoaRName, @@ -82,7 +82,7 @@ public PowerDnsWriter( this.tldZoneName = getCanonicalHostName(tldZoneName); this.powerDnsDefaultSoaMName = powerDnsDefaultSoaMName; this.powerDnsDefaultSoaRName = powerDnsDefaultSoaRName; - this.powerDnsClient = new PowerDNSClient(powerDnsHost, powerDnsApiKey); + this.powerDnsClient = new PowerDNSClient(powerDnsBaseUrl, powerDnsApiKey); } /** @@ -93,7 +93,7 @@ public PowerDnsWriter( */ @Override public void publishDomain(String domainName) { - String normalizedDomainName = getCanonicalHostName(domainName); + String normalizedDomainName = getSanitizedHostName(domainName); logger.atInfo().log("Staging domain %s for PowerDNS", normalizedDomainName); super.publishDomain(normalizedDomainName); } @@ -106,7 +106,7 @@ public void publishDomain(String domainName) { */ @Override public void publishHost(String hostName) { - String normalizedHostName = getCanonicalHostName(hostName); + String normalizedHostName = getSanitizedHostName(hostName); logger.atInfo().log("Staging host %s for PowerDNS", normalizedHostName); super.publishHost(normalizedHostName); } @@ -115,9 +115,7 @@ public void publishHost(String hostName) { protected void commitUnchecked() { try { // persist staged changes to PowerDNS - logger.atInfo().log( - "Committing updates to PowerDNS for TLD %s on server %s", - tldZoneName, powerDnsClient.getServerId()); + logger.atInfo().log("Committing updates to PowerDNS for TLD %s", tldZoneName); // convert the update to a PowerDNS Zone object Zone zone = convertUpdateToZone(update); @@ -125,6 +123,7 @@ protected void commitUnchecked() { // call the PowerDNS API to commit the changes powerDnsClient.patchZone(zone); } catch (Exception e) { + logger.atSevere().withCause(e).log("Commit to PowerDNS failed for TLD: %s", tldZoneName); throw new RuntimeException("publishDomain failed for TLD: " + tldZoneName, e); } } @@ -138,15 +137,17 @@ protected void commitUnchecked() { * @throws IOException if the zone is not found */ private Zone convertUpdateToZone(Update update) throws IOException { + // Convert the Update object to a Zone object + logger.atInfo().log("Converting PowerDNS TLD zone %s update: %s", tldZoneName, update); + // Iterate the update records and prepare them as PowerDNS RRSet objects, referencing the // following source code to determine the usage of the org.xbill.DNS.Record object: // // https://www.javadoc.io/doc/dnsjava/dnsjava/3.2.1/org/xbill/DNS/Record.html // https://github.com/dnsjava/dnsjava/blob/master/src/main/java/org/xbill/DNS/Record.java#L324-L350 ArrayList allRRSets = new ArrayList(); - ArrayList filteredRRSets = new ArrayList(); for (Record r : update.getSection(Section.UPDATE)) { - logger.atInfo().log("Processing TLD zone %s update record: %s", tldZoneName, r); + logger.atInfo().log("Processing PowerDNS TLD zone %s update record: %s", tldZoneName, r); // create the base PowerDNS RRSet object RRSet record = new RRSet(); @@ -172,33 +173,17 @@ private Zone convertUpdateToZone(Update update) throws IOException { record.setChangeType(RRSet.ChangeType.REPLACE); } - // Add record to lists of all and filtered RRSets. The first list is used to track all RRSets - // for the TLD zone, while the second list is used to track the RRSets that will be sent to - // the PowerDNS API. By default, there is a deletion record created by the parent class for - // every domain name and record type combination. However, PowerDNS only expects to see a - // deletion record if the record should be removed from the TLD zone. + // Add record to lists of RRSets allRRSets.add(record); - filteredRRSets.add(record); } - // remove deletion records for a domain if there is a subsequent update enqueued - // for the same domain name and record type combination - allRRSets.stream() - .filter(r -> r.getChangeType() == RRSet.ChangeType.REPLACE) - .forEach( - r -> { - filteredRRSets.removeIf( - fr -> - fr.getName().equals(r.getName()) - && fr.getType().equals(r.getType()) - && fr.getChangeType() == RRSet.ChangeType.DELETE); - }); - // prepare a PowerDNS zone object containing the TLD record updates - Zone preparedTldZone = getTldZoneForUpdate(filteredRRSets); + Zone preparedTldZone = getTldZoneForUpdate(allRRSets); // return the prepared TLD zone - logger.atInfo().log("Prepared TLD zone %s for PowerDNS: %s", tldZoneName, preparedTldZone); + logger.atInfo().log( + "Successfully processed PowerDNS TLD zone %s update record: %s", + tldZoneName, preparedTldZone); return preparedTldZone; } @@ -216,6 +201,17 @@ private String getCanonicalHostName(String hostName) { return canonicalHostName.toLowerCase(Locale.US); } + /** + * Returns the sanitized host name, which is the host name without the trailing dot. + * + * @param hostName the fully qualified hostname + * @return the sanitized host name + */ + private String getSanitizedHostName(String hostName) { + // return the host name without the trailing dot + return hostName.endsWith(".") ? hostName.substring(0, hostName.length() - 1) : hostName; + } + /** * Prepare the TLD zone for updates by clearing the RRSets and incrementing the serial number. * @@ -225,6 +221,7 @@ private String getCanonicalHostName(String hostName) { private Zone getTldZoneForUpdate(List records) { Zone tldZone = new Zone(); tldZone.setId(getTldZoneId()); + tldZone.setName(getSanitizedHostName(tldZoneName)); tldZone.setRrsets(records); return tldZone; } @@ -248,7 +245,7 @@ private Zone getTldZoneByName() throws IOException { // up step using pdnsutil command line tool. try { // base TLD zone object - logger.atInfo().log("Creating new TLD zone %s", tldZoneName); + logger.atInfo().log("Creating new PowerDNS TLD zone %s", tldZoneName); Zone newTldZone = new Zone(); newTldZone.setName(tldZoneName); newTldZone.setKind(Zone.ZoneKind.Master); @@ -274,11 +271,11 @@ private Zone getTldZoneByName() throws IOException { // create the TLD zone and log the result Zone createdTldZone = powerDnsClient.createZone(newTldZone); - logger.atInfo().log("Successfully created TLD zone %s", tldZoneName); + logger.atInfo().log("Successfully created PowerDNS TLD zone %s", tldZoneName); return createdTldZone; } catch (Exception e) { // log the error and continue - logger.atWarning().log("Failed to create TLD zone %s: %s", tldZoneName, e); + logger.atWarning().log("Failed to create PowerDNS TLD zone %s: %s", tldZoneName, e); } // otherwise, throw an exception @@ -300,8 +297,8 @@ private String getTldZoneId() { } catch (Exception e) { // TODO: throw this exception once PowerDNS is available, but for now we are just // going to return a dummy ID - logger.atWarning().log("Failed to get TLD zone ID for %s: %s", tldZoneName, e); - return String.format("dummy-zone-id-%s", tldZoneName); + logger.atWarning().log("Failed to get PowerDNS TLD zone ID for %s: %s", tldZoneName, e); + return tldZoneName; } }); } diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java index f0c74e3ba89..3b4e529fb81 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -26,6 +26,7 @@ import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; +import okio.Buffer; /** * A client for the PowerDNS API. @@ -40,15 +41,15 @@ *

The API key is retrieved from the environment variable {@code POWERDNS_API_KEY}. */ public class PowerDNSClient { - // static fields - private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + // class variables private final OkHttpClient httpClient; private final ObjectMapper objectMapper; private final String baseUrl; private final String apiKey; - // dynamic fields - private String serverId; + // static fields + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static String serverId; public PowerDNSClient(String baseUrl, String apiKey) { // initialize the base URL and API key. The base URL should be of the form @@ -61,27 +62,47 @@ public PowerDNSClient(String baseUrl, String apiKey) { this.httpClient = new OkHttpClient(); this.objectMapper = new ObjectMapper(); - // initialize the Server ID by querying the server list and choosing - // the first entry - try { - List servers = listServers(); - if (servers.isEmpty()) { - throw new IOException("No servers found"); + // initialize the Server ID + initializeServerId(); + } + + private synchronized void initializeServerId() { + if (serverId == null) { + try { + // list the servers and throw an exception if no servers are found + List servers = listServers(); + if (servers.isEmpty()) { + throw new IOException("No servers found"); + } + + // set the server ID to the first server in the list + serverId = servers.get(0).getId(); + } catch (Exception e) { + logger.atWarning().withCause(e).log("Failed to get PowerDNS server ID"); } - this.serverId = servers.get(0).getId(); - } catch (IOException e) { - // TODO: throw this exception once PowerDNS is available, but for now we are just - // going to return a dummy ID - logger.atWarning().log("Failed to get server ID: %s", e); - this.serverId = "dummy-server-id"; + } + } + + private String bodyToString(final RequestBody requestBody) throws IOException { + try (Buffer buffer = new Buffer()) { + if (requestBody != null) requestBody.writeTo(buffer); + else return ""; + return buffer.readUtf8(); } } private Response logAndExecuteRequest(Request request) throws IOException { // log the request and create timestamp for the start time - logger.atInfo().log("Executing PowerDNS request: %s, body: %s", request, request.body()); + logger.atInfo().log( + "Executing PowerDNS request: %s, body: %s", + request, request.body() != null ? bodyToString(request.body()) : null); long startTime = System.currentTimeMillis(); + // validate the server ID is initialized + if (request.url().toString().contains("/servers/null")) { + throw new IOException("Server ID is not initialized"); + } + // execute the request and log the response Response response = httpClient.newCall(request).execute(); logger.atInfo().log("PowerDNS response: %s", response); @@ -126,14 +147,6 @@ public Server getServer() throws IOException { } } - public String getServerId() { - return serverId; - } - - public void setServerId(String serverId) { - this.serverId = serverId; - } - public List listZones() throws IOException { Request request = new Request.Builder() diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java index ba49afa7d01..b10af9ab08b 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java @@ -14,8 +14,10 @@ package google.registry.dns.writer.powerdns.client.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +@JsonIgnoreProperties(ignoreUnknown = true) public class Comment { @JsonProperty("content") private String content; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java index 0620fb757fd..97d46412cfe 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java @@ -14,9 +14,11 @@ package google.registry.dns.writer.powerdns.client.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +@JsonIgnoreProperties(ignoreUnknown = true) public class RRSet { @JsonProperty("name") private String name; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java index 1ad33e4f209..7f3ced1ecf7 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java @@ -14,8 +14,10 @@ package google.registry.dns.writer.powerdns.client.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +@JsonIgnoreProperties(ignoreUnknown = true) public class RecordObject { @JsonProperty("content") private String content; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java index 024ee40946a..518be6aa120 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java @@ -14,8 +14,10 @@ package google.registry.dns.writer.powerdns.client.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +@JsonIgnoreProperties(ignoreUnknown = true) public class Server { @JsonProperty("type") private String type; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java index ddc406510ad..688981beb3b 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java @@ -14,9 +14,11 @@ package google.registry.dns.writer.powerdns.client.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +@JsonIgnoreProperties(ignoreUnknown = true) public class Zone { @JsonProperty("id") private String id; @@ -278,7 +280,8 @@ public String toString() { long updatedCount = rrsets.stream().filter(rrset -> rrset.getChangeType() == RRSet.ChangeType.REPLACE).count(); return String.format( - "{id:%s,name:%s,deleted:%d,updated:%d}", id, name, deletedCount, updatedCount); + "{id:%s,name:%s,deleted:%d,updated:%d,total:%d}", + id, name, deletedCount, updatedCount, rrsets.size()); } public enum ZoneKind { diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java index 96f912dbd07..5ea879796a6 100644 --- a/core/src/main/java/google/registry/module/RequestComponent.java +++ b/core/src/main/java/google/registry/module/RequestComponent.java @@ -42,7 +42,6 @@ import google.registry.dns.writer.clouddns.CloudDnsWriterModule; import google.registry.dns.writer.dnsupdate.DnsUpdateConfigModule; import google.registry.dns.writer.dnsupdate.DnsUpdateWriterModule; -import google.registry.dns.writer.powerdns.PowerDnsConfigModule; import google.registry.dns.writer.powerdns.PowerDnsWriterModule; import google.registry.export.ExportDomainListsAction; import google.registry.export.ExportPremiumTermsAction; @@ -150,7 +149,6 @@ DnsModule.class, DnsUpdateConfigModule.class, DnsUpdateWriterModule.class, - PowerDnsConfigModule.class, PowerDnsWriterModule.class, EppTlsModule.class, EppToolModule.class, diff --git a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java index 4125bce0655..af041fef322 100644 --- a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java +++ b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java @@ -38,7 +38,6 @@ import google.registry.dns.writer.clouddns.CloudDnsWriterModule; import google.registry.dns.writer.dnsupdate.DnsUpdateConfigModule; import google.registry.dns.writer.dnsupdate.DnsUpdateWriterModule; -import google.registry.dns.writer.powerdns.PowerDnsConfigModule; import google.registry.dns.writer.powerdns.PowerDnsWriterModule; import google.registry.export.ExportDomainListsAction; import google.registry.export.ExportPremiumTermsAction; @@ -91,7 +90,6 @@ DnsModule.class, DnsUpdateConfigModule.class, DnsUpdateWriterModule.class, - PowerDnsConfigModule.class, PowerDnsWriterModule.class, IcannReportingModule.class, RdeModule.class, diff --git a/core/src/main/java/google/registry/tools/RegistryToolComponent.java b/core/src/main/java/google/registry/tools/RegistryToolComponent.java index 5aef100085a..e9215e54266 100644 --- a/core/src/main/java/google/registry/tools/RegistryToolComponent.java +++ b/core/src/main/java/google/registry/tools/RegistryToolComponent.java @@ -26,7 +26,6 @@ import google.registry.dns.writer.VoidDnsWriterModule; import google.registry.dns.writer.clouddns.CloudDnsWriterModule; import google.registry.dns.writer.dnsupdate.DnsUpdateWriterModule; -import google.registry.dns.writer.powerdns.PowerDnsConfigModule; import google.registry.dns.writer.powerdns.PowerDnsWriterModule; import google.registry.keyring.KeyringModule; import google.registry.keyring.api.KeyModule; @@ -61,7 +60,6 @@ CloudDnsWriterModule.class, CloudTasksUtilsModule.class, DnsUpdateWriterModule.class, - PowerDnsConfigModule.class, PowerDnsWriterModule.class, GsonModule.class, KeyModule.class, From 0a28a124d0212a99b8ef97b4da721a903b600615 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Tue, 13 May 2025 14:10:04 -0400 Subject: [PATCH 13/23] adjust PowerDNS zone update body format --- .../dns/writer/powerdns/PowerDnsWriter.java | 62 ++++++++++++++----- .../powerdns/client/PowerDNSClient.java | 6 +- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 2b9666a220d..0a7a51898e3 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -30,6 +30,7 @@ import java.util.List; import java.util.Locale; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; import org.joda.time.Duration; import org.xbill.DNS.Record; import org.xbill.DNS.Section; @@ -149,11 +150,18 @@ private Zone convertUpdateToZone(Update update) throws IOException { for (Record r : update.getSection(Section.UPDATE)) { logger.atInfo().log("Processing PowerDNS TLD zone %s update record: %s", tldZoneName, r); - // create the base PowerDNS RRSet object - RRSet record = new RRSet(); - record.setName(r.getName().toString()); - record.setTtl(r.getTTL()); - record.setType(Type.string(r.getType())); + // find an existing RRSET matching record name and type, or create a new one + // if an existing RRSET is not found + RRSet rrSet = + allRRSets.stream() + .filter( + rrset -> + rrset.getName().equals(r.getName().toString()) + && rrset.getType().equals(Type.string(r.getType()))) + .findFirst() + .orElse( + appendRRSet( + allRRSets, r.getName().toString(), Type.string(r.getType()), r.getTTL())); // determine if this is a record update or a record deletion Boolean isDelete = r.getTTL() == 0 && r.rdataToString().equals(""); @@ -161,24 +169,26 @@ private Zone convertUpdateToZone(Update update) throws IOException { // handle record updates and deletions if (isDelete) { // indicate that this is a record deletion - record.setChangeType(RRSet.ChangeType.DELETE); + rrSet.setChangeType(RRSet.ChangeType.DELETE); } else { + // indicate that this is a record update + rrSet.setChangeType(RRSet.ChangeType.REPLACE); + // add the record content RecordObject recordObject = new RecordObject(); recordObject.setContent(r.rdataToString()); recordObject.setDisabled(false); - record.setRecords(new ArrayList(Arrays.asList(recordObject))); - // indicate that this is a record update - record.setChangeType(RRSet.ChangeType.REPLACE); + // append the record to the RRSet + rrSet.getRecords().add(recordObject); } - - // Add record to lists of RRSets - allRRSets.add(record); } - // prepare a PowerDNS zone object containing the TLD record updates - Zone preparedTldZone = getTldZoneForUpdate(allRRSets); + // prepare a PowerDNS zone object containing the TLD record updates using the RRSet objects + // that have a valid change type + Zone preparedTldZone = + getTldZoneForUpdate( + allRRSets.stream().filter(v -> v.getChangeType() != null).collect(Collectors.toList())); // return the prepared TLD zone logger.atInfo().log( @@ -187,6 +197,30 @@ private Zone convertUpdateToZone(Update update) throws IOException { return preparedTldZone; } + /** + * Create a new RRSet object. + * + * @param rrsets the list of RRSets + * @param name the name of the RRSet + * @param type the type of the RRSet + * @param ttl the TTL of the RRSet + * @return the new RRSet object + */ + private RRSet appendRRSet(List rrsets, String name, String type, long ttl) { + // create the base PowerDNS RRSet object + RRSet rrset = new RRSet(); + rrset.setName(name); + rrset.setType(type); + rrset.setTtl(ttl); + rrset.setRecords(new ArrayList()); + + // add the RRSet to the list of RRSets + rrsets.add(rrset); + + // return the new RRSet object + return rrset; + } + /** * Returns the presentation format ending in a dot used for an given hostname. * diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java index 3b4e529fb81..d185e56d75e 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -94,8 +94,10 @@ private String bodyToString(final RequestBody requestBody) throws IOException { private Response logAndExecuteRequest(Request request) throws IOException { // log the request and create timestamp for the start time logger.atInfo().log( - "Executing PowerDNS request: %s, body: %s", - request, request.body() != null ? bodyToString(request.body()) : null); + "Executing PowerDNS request: %s, url: %s, body: %s", + request.method(), + request.url(), + request.body() != null ? bodyToString(request.body()) : null); long startTime = System.currentTimeMillis(); // validate the server ID is initialized From c49477d255ce16a658a3b88b59c11cb08c460ade Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Tue, 13 May 2025 16:25:00 -0400 Subject: [PATCH 14/23] only process delete change types when no other updates for a given domain --- .../dns/writer/powerdns/PowerDnsWriter.java | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 0a7a51898e3..f97db533ac6 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -49,7 +49,10 @@ public class PowerDnsWriter extends DnsUpdateWriter { private final String powerDnsDefaultSoaMName; private final String powerDnsDefaultSoaRName; private final PowerDNSClient powerDnsClient; + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final ArrayList supportedRecordTypes = + new ArrayList<>(Arrays.asList("DS", "NS")); private static final ConcurrentHashMap zoneIdCache = new ConcurrentHashMap<>(); /** @@ -141,31 +144,64 @@ private Zone convertUpdateToZone(Update update) throws IOException { // Convert the Update object to a Zone object logger.atInfo().log("Converting PowerDNS TLD zone %s update: %s", tldZoneName, update); + // generate a list of records to process + List updateRecordsToProcess = new ArrayList<>(); + for (Record r : update.getSection(Section.UPDATE)) { + // determine if any updates exist for this domain + Boolean isAnyDomainUpdate = + update.getSection(Section.UPDATE).stream() + .anyMatch(record -> record.getName().equals(r.getName()) && !isDeleteRecord(record)); + + // special processing for ANY record deletions + if (isDeleteRecord(r) && Type.string(r.getType()).equals("ANY")) { + // only add a deletion record if there are no other updates for this domain + if (!isAnyDomainUpdate) { + // add a delete record for each of the supported record types + for (String recordType : supportedRecordTypes) { + Record deleteRecord = + Record.newRecord(r.getName(), Type.value(recordType), r.getDClass(), r.getTTL()); + updateRecordsToProcess.add(deleteRecord); + } + } + } else { + // add the record to the list of records to process + updateRecordsToProcess.add(r); + } + } + // Iterate the update records and prepare them as PowerDNS RRSet objects, referencing the // following source code to determine the usage of the org.xbill.DNS.Record object: // // https://www.javadoc.io/doc/dnsjava/dnsjava/3.2.1/org/xbill/DNS/Record.html // https://github.com/dnsjava/dnsjava/blob/master/src/main/java/org/xbill/DNS/Record.java#L324-L350 ArrayList allRRSets = new ArrayList(); - for (Record r : update.getSection(Section.UPDATE)) { - logger.atInfo().log("Processing PowerDNS TLD zone %s update record: %s", tldZoneName, r); + for (Record r : updateRecordsToProcess) { + // skip unsupported record types + if (!supportedRecordTypes.contains(Type.string(r.getType()))) { + logger.atInfo().log( + "Skipping unsupported PowerDNS update record type: %s", Type.string(r.getType())); + continue; + } + + // determine if this is a record update or a record deletion + Boolean isDelete = isDeleteRecord(r); // find an existing RRSET matching record name and type, or create a new one // if an existing RRSET is not found + logger.atInfo().log("Processing PowerDNS TLD zone %s update record: %s", tldZoneName, r); RRSet rrSet = allRRSets.stream() .filter( rrset -> rrset.getName().equals(r.getName().toString()) - && rrset.getType().equals(Type.string(r.getType()))) + && rrset.getType().equals(Type.string(r.getType())) + && ((isDelete && rrset.getChangeType() == RRSet.ChangeType.DELETE) + || (!isDelete && rrset.getChangeType() == RRSet.ChangeType.REPLACE))) .findFirst() .orElse( appendRRSet( allRRSets, r.getName().toString(), Type.string(r.getType()), r.getTTL())); - // determine if this is a record update or a record deletion - Boolean isDelete = r.getTTL() == 0 && r.rdataToString().equals(""); - // handle record updates and deletions if (isDelete) { // indicate that this is a record deletion @@ -336,4 +372,14 @@ private String getTldZoneId() { } }); } + + /** + * Determine if a record is a delete record. + * + * @param r the record to check + * @return true if the record is a delete record, false otherwise + */ + private Boolean isDeleteRecord(Record r) { + return r.getTTL() == 0 && r.rdataToString().equals(""); + } } From 8fb99bc490f893437d42ecb0dc53a951c25daf17 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Tue, 13 May 2025 20:54:37 -0400 Subject: [PATCH 15/23] support A/AAAA glue records --- .../google/registry/dns/writer/powerdns/PowerDnsWriter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index f97db533ac6..f7163359429 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -52,7 +52,7 @@ public class PowerDnsWriter extends DnsUpdateWriter { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final ArrayList supportedRecordTypes = - new ArrayList<>(Arrays.asList("DS", "NS")); + new ArrayList<>(Arrays.asList("A", "AAAA", "DS", "NS")); private static final ConcurrentHashMap zoneIdCache = new ConcurrentHashMap<>(); /** From 5e71300cbbea4c827c1582f625bc6909ac81dce0 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Thu, 15 May 2025 09:49:57 -0400 Subject: [PATCH 16/23] automatic ZSK rotation --- .../registry/config/RegistryConfig.java | 20 +- .../config/RegistryConfigSettings.java | 1 + .../registry/config/files/default-config.yaml | 1 + .../dns/writer/powerdns/PowerDnsWriter.java | 346 +++++++++++++++--- .../powerdns/client/PowerDNSClient.java | 246 ++++++++++++- .../powerdns/client/model/Cryptokey.java | 168 +++++++++ .../powerdns/client/model/Metadata.java | 57 +++ 7 files changed, 778 insertions(+), 61 deletions(-) create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/model/Cryptokey.java create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/model/Metadata.java diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index 1ca12b52fca..9a6cb10037a 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -134,7 +134,7 @@ public static String provideLocationId(RegistryConfigSettings config) { @Provides @Config("powerDnsBaseUrl") public static String providePowerDnsBaseUrl(RegistryConfigSettings config) { - if (config.powerDns != null && config.powerDns.baseUrl != null) { + if (config.powerDns.baseUrl != null) { return config.powerDns.baseUrl; } return "http://localhost:8081/api/v1"; @@ -144,17 +144,27 @@ public static String providePowerDnsBaseUrl(RegistryConfigSettings config) { @Provides @Config("powerDnsApiKey") public static String providePowerDnsApiKey(RegistryConfigSettings config) { - if (config.powerDns != null && config.powerDns.apiKey != null) { + if (config.powerDns.apiKey != null) { return config.powerDns.apiKey; } - return "dummy-api-key"; + return "example-api-key"; + } + + /** Whether DNSSEC is enabled for the PowerDNS server. */ + @Provides + @Config("powerDnsDnssecEnabled") + public static Boolean providePowerDnsDnssecEnabled(RegistryConfigSettings config) { + if (config.powerDns.dnssecEnabled != null) { + return config.powerDns.dnssecEnabled; + } + return false; } /** Default SOA MNAME for the TLD zone. */ @Provides @Config("powerDnsDefaultSoaMName") public static String providePowerDnsDefaultSoaMName(RegistryConfigSettings config) { - if (config.powerDns != null && config.powerDns.defaultSoaMName != null) { + if (config.powerDns.defaultSoaMName != null) { return config.powerDns.defaultSoaMName; } return "a.gtld-servers.net."; @@ -164,7 +174,7 @@ public static String providePowerDnsDefaultSoaMName(RegistryConfigSettings confi @Provides @Config("powerDnsDefaultSoaRName") public static String providePowerDnsDefaultSoaRName(RegistryConfigSettings config) { - if (config.powerDns != null && config.powerDns.defaultSoaRName != null) { + if (config.powerDns.defaultSoaRName != null) { return config.powerDns.defaultSoaRName; } return "nstld.verisign-grs.com."; diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index b912c268f0f..dd1d6c841ac 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -63,6 +63,7 @@ public static class GcpProject { public static class PowerDns { public String baseUrl; public String apiKey; + public Boolean dnssecEnabled; public String defaultSoaMName; public String defaultSoaRName; } diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index c580b53132f..16796d267d0 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -30,6 +30,7 @@ gcpProject: powerDns: baseUrl: http://localhost:8081/api/v1 apiKey: example-api-key + dnssecEnabled: false defaultSoaMName: a.example.com. defaultSoaRName: nstld.example.com. diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index f7163359429..d91ddbd4819 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -14,11 +14,16 @@ package google.registry.dns.writer.powerdns; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; import com.google.common.flogger.FluentLogger; import google.registry.config.RegistryConfig.Config; import google.registry.dns.writer.DnsWriterZone; import google.registry.dns.writer.dnsupdate.DnsUpdateWriter; import google.registry.dns.writer.powerdns.client.PowerDNSClient; +import google.registry.dns.writer.powerdns.client.model.Cryptokey; +import google.registry.dns.writer.powerdns.client.model.Cryptokey.KeyType; +import google.registry.dns.writer.powerdns.client.model.Metadata; import google.registry.dns.writer.powerdns.client.model.RRSet; import google.registry.dns.writer.powerdns.client.model.RecordObject; import google.registry.dns.writer.powerdns.client.model.Zone; @@ -43,17 +48,36 @@ * This request is then converted into a PowerDNS Zone object and sent to the PowerDNS API. */ public class PowerDnsWriter extends DnsUpdateWriter { + // Class configuration public static final String NAME = "PowerDnsWriter"; + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + // PowerDNS configuration private final String tldZoneName; - private final String powerDnsDefaultSoaMName; - private final String powerDnsDefaultSoaRName; + private final String defaultSoaMName; + private final String defaultSoaRName; + private final Boolean dnssecEnabled; private final PowerDNSClient powerDnsClient; - private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + // Supported record types to synchronize with PowerDNS private static final ArrayList supportedRecordTypes = new ArrayList<>(Arrays.asList("A", "AAAA", "DS", "NS")); + + // Zone ID cache configuration private static final ConcurrentHashMap zoneIdCache = new ConcurrentHashMap<>(); + private static long zoneIdCacheExpiration = 0; + private static int defaultZoneTtl = 3600; // 1 hour in seconds + + // DNSSEC configuration + private static final String DNSSEC_ALGORITHM = "rsasha256"; + private static final String DNSSEC_SOA_EDIT = "INCREMENT-WEEKS"; + private static final int DNSSEC_KSK_BITS = 2048; + private static final int DNSSEC_ZSK_BITS = 1024; + private static final long DNSSEC_ZSK_EXPIRY_MS = 30L * 24 * 60 * 60 * 1000; // 30 days + private static final long DNSSEC_ZSK_ACTIVATION_MS = + 1000L * 2 * defaultZoneTtl; // twice the default zone TTL in milliseconds + private static final String DNSSEC_ZSK_EXPIRE_FLAG = "DNSSEC-ZSK-EXPIRE-DATE"; + private static final String DNSSEC_ZSK_ACTIVATION_FLAG = "DNSSEC-ZSK-ACTIVATION-DATE"; /** * Class constructor. @@ -76,6 +100,7 @@ public PowerDnsWriter( @Config("powerDnsApiKey") String powerDnsApiKey, @Config("powerDnsDefaultSoaMName") String powerDnsDefaultSoaMName, @Config("powerDnsDefaultSoaRName") String powerDnsDefaultSoaRName, + @Config("powerDnsDnssecEnabled") Boolean powerDnsDnssecEnabled, Clock clock) { // call the DnsUpdateWriter constructor, omitting the transport parameter @@ -84,8 +109,9 @@ public PowerDnsWriter( // Initialize the PowerDNS client this.tldZoneName = getCanonicalHostName(tldZoneName); - this.powerDnsDefaultSoaMName = powerDnsDefaultSoaMName; - this.powerDnsDefaultSoaRName = powerDnsDefaultSoaRName; + this.defaultSoaMName = powerDnsDefaultSoaMName; + this.defaultSoaRName = powerDnsDefaultSoaRName; + this.dnssecEnabled = powerDnsDnssecEnabled; this.powerDnsClient = new PowerDNSClient(powerDnsBaseUrl, powerDnsApiKey); } @@ -257,6 +283,215 @@ private RRSet appendRRSet(List rrsets, String name, String type, long ttl return rrset; } + /** + * Create a new TLD zone for the TLD associated with the PowerDnsWriter. The zone will be created + * with a basic SOA record and not yet configured with DNSSEC. + * + * @return the new TLD zone + * @throws IOException if the TLD zone is not found + */ + private Zone createZone() throws IOException { + // base TLD zone object + logger.atInfo().log("Creating new PowerDNS TLD zone %s", tldZoneName); + Zone newTldZone = new Zone(); + newTldZone.setName(tldZoneName); + newTldZone.setKind(Zone.ZoneKind.Master); + + // create an initial SOA record, which may be modified later by an administrator + // or an automated onboarding process + RRSet soaRecord = new RRSet(); + soaRecord.setChangeType(RRSet.ChangeType.REPLACE); + soaRecord.setName(tldZoneName); + soaRecord.setTtl(defaultZoneTtl); + soaRecord.setType("SOA"); + + // add content to the SOA record content from default configuration + RecordObject soaRecordContent = new RecordObject(); + soaRecordContent.setContent( + String.format( + "%s %s 1 900 1800 6048000 %s", defaultSoaMName, defaultSoaRName, defaultZoneTtl)); + soaRecordContent.setDisabled(false); + soaRecord.setRecords(new ArrayList(Arrays.asList(soaRecordContent))); + + // add the SOA record to the new TLD zone + newTldZone.setRrsets(new ArrayList(Arrays.asList(soaRecord))); + + // create the TLD zone and log the result + Zone createdTldZone = powerDnsClient.createZone(newTldZone); + logger.atInfo().log("Successfully created PowerDNS TLD zone %s", tldZoneName); + + // return the created TLD zone + return createdTldZone; + } + + /** + * Validate the DNSSEC configuration for the TLD zone. If DNSSEC is not enabled, it will be + * enabled and the KSK and ZSK entries will be created. If DNSSEC is enabled, the ZSK expiration + * date will be checked and the ZSK will be rolled over if it has expired. + * + * @param zone the TLD zone to validate + */ + private void validateDnssecConfig(Zone zone) { + // check if DNSSEC configuration is required + if (!dnssecEnabled) { + logger.atInfo().log( + "DNSSEC validation is not required for PowerDNS TLD zone %s", zone.getName()); + return; + } + + try { + // check if DNSSEC is already enabled for the TLD zone + if (!zone.getDnssec()) { + // DNSSEC is not enabled, so we need to enable it + logger.atInfo().log("Enabling DNSSEC for PowerDNS TLD zone %s", zone.getName()); + + // create the KSK and ZSK entries for the TLD zone + powerDnsClient.createCryptokey( + zone.getId(), + Cryptokey.createCryptokey(KeyType.ksk, DNSSEC_KSK_BITS, true, true, DNSSEC_ALGORITHM)); + powerDnsClient.createCryptokey( + zone.getId(), + Cryptokey.createCryptokey(KeyType.zsk, DNSSEC_ZSK_BITS, true, true, DNSSEC_ALGORITHM)); + + // create the SOA-EDIT metadata entry for the TLD zone + powerDnsClient.createMetadata( + zone.getId(), Metadata.createMetadata("SOA-EDIT", Arrays.asList(DNSSEC_SOA_EDIT))); + + // update the zone account field with the expiration timestamp + Zone updatedZone = new Zone(); + updatedZone.setId(zone.getId()); + updatedZone.setApiRectify(true); + updatedZone.setAccount( + String.format( + "%s:%s", + DNSSEC_ZSK_EXPIRE_FLAG, System.currentTimeMillis() + DNSSEC_ZSK_EXPIRY_MS)); + powerDnsClient.putZone(updatedZone); + + // attempt to manually rectify the TLD zone + try { + logger.atInfo().log("Rectifying PowerDNS TLD zone %s", zone.getName()); + powerDnsClient.rectifyZone(zone.getId()); + } catch (Exception rectifyException) { + logger.atWarning().withCause(rectifyException).log( + "Failed to complete rectification of PowerDNS TLD zone %s", zone.getName()); + } + + // retrieve the zone and print the new DS values + logger.atInfo().log("Successfully enabled DNSSEC for PowerDNS TLD zone %s", zone.getName()); + } else { + // DNSSEC is enabled, so we need to validate the configuration + logger.atInfo().log( + "Validating existing DNSSEC configuration for PowerDNS TLD zone %s", zone.getName()); + + // check for a ZSK expiration flag + if (zone.getAccount().contains(DNSSEC_ZSK_EXPIRE_FLAG)) { + // check for an expired ZSK expiration date + String dnssecZskExpireDate = Iterables.get(Splitter.on(':').split(zone.getAccount()), 1); + if (System.currentTimeMillis() > Long.parseLong(dnssecZskExpireDate)) { + // start a ZSK rollover + logger.atInfo().log( + "ZSK has expired, starting rollover for PowerDNS TLD zone %s", zone.getName()); + + // create a new inactive ZSK + powerDnsClient.createCryptokey( + zone.getId(), + Cryptokey.createCryptokey( + KeyType.zsk, DNSSEC_ZSK_BITS, false, true, DNSSEC_ALGORITHM)); + + // update the zone account field with the activation timestamp + Zone updatedZone = new Zone(); + updatedZone.setId(zone.getId()); + updatedZone.setAccount( + String.format( + "%s:%s", + DNSSEC_ZSK_ACTIVATION_FLAG, + System.currentTimeMillis() + DNSSEC_ZSK_ACTIVATION_MS)); + powerDnsClient.putZone(updatedZone); + + // log the rollover event + logger.atInfo().log( + "Successfully started ZSK rollover for PowerDNS TLD zone %s", zone.getName()); + } else { + // ZSK is not expired, so we need to log the current ZSK activation date + logger.atInfo().log( + "DNSSEC configuration for PowerDNS TLD zone %s is valid for another %s seconds", + zone.getName(), + (Long.parseLong(dnssecZskExpireDate) - System.currentTimeMillis()) / 1000); + } + } + + // check for a ZSK rollover key activation flag + else if (zone.getAccount().contains(DNSSEC_ZSK_ACTIVATION_FLAG)) { + // check for a ZSK activation date + String dnssecZskActivationDate = + Iterables.get(Splitter.on(':').split(zone.getAccount()), 1); + if (System.currentTimeMillis() > Long.parseLong(dnssecZskActivationDate)) { + // ZSK activation window has elapsed, so we need to activate the ZSK + logger.atInfo().log( + "ZSK activation window has elapsed, activating ZSK for PowerDNS TLD zone %s", + zone.getName()); + + // list all crypto keys for the TLD zone + List cryptokeys = powerDnsClient.listCryptokeys(zone.getId()); + + // identify the active and inactive ZSKs + Cryptokey activeZsk = + cryptokeys.stream() + .filter(c -> c.getActive() && c.getKeytype() == KeyType.zsk) + .findFirst() + .orElse(null); + Cryptokey inactiveZsk = + cryptokeys.stream() + .filter(c -> !c.getActive() && c.getKeytype() == KeyType.zsk) + .findFirst() + .orElse(null); + + // if both keys are found, complete the ZSK rollover + if (activeZsk != null && inactiveZsk != null) { + // activate the inactive ZSK + inactiveZsk.setActive(true); + powerDnsClient.modifyCryptokey(zone.getId(), inactiveZsk); + + // delete the active ZSK + powerDnsClient.deleteCryptokey(zone.getId(), activeZsk.getId()); + + // update the zone account field with the expiration timestamp + Zone updatedZone = new Zone(); + updatedZone.setId(zone.getId()); + updatedZone.setAccount( + String.format( + "%s:%s", + DNSSEC_ZSK_EXPIRE_FLAG, System.currentTimeMillis() + DNSSEC_ZSK_EXPIRY_MS)); + powerDnsClient.putZone(updatedZone); + + // log the ZSK rollover event + logger.atInfo().log( + "Successfully completed ZSK rollover for PowerDNS TLD zone %s", zone.getName()); + } else { + // unable to complete the ZSK rollover + logger.atSevere().log( + "Unable to locate active and inactive ZSKs for PowerDNS TLD zone %s. Manual" + + " intervention required to complete the ZSK rollover.", + zone.getName()); + return; + } + } else { + // ZSK activation date has not yet elapsed, so we need to log the current ZSK activation + // date + logger.atInfo().log( + "ZSK rollover for PowerDNS TLD zone %s is in progress for another %s seconds", + zone.getName(), + (Long.parseLong(dnssecZskActivationDate) - System.currentTimeMillis()) / 1000); + } + } + } + } catch (Exception e) { + // log the error gracefully and allow processing to continue + logger.atSevere().withCause(e).log( + "Failed to validate DNSSEC configuration for PowerDNS TLD zone %s", zone.getName()); + } + } + /** * Returns the presentation format ending in a dot used for an given hostname. * @@ -288,7 +523,7 @@ private String getSanitizedHostName(String hostName) { * @param records the set of RRSet records that will be sent to the PowerDNS API * @return the prepared TLD zone */ - private Zone getTldZoneForUpdate(List records) { + private Zone getTldZoneForUpdate(List records) throws IOException { Zone tldZone = new Zone(); tldZone.setId(getTldZoneId()); tldZone.setName(getSanitizedHostName(tldZoneName)); @@ -297,52 +532,29 @@ private Zone getTldZoneForUpdate(List records) { } /** - * Get the TLD zone by name. + * Get the TLD zone by name and validate the zone's configuration before returning. * * @return the TLD zone * @throws IOException if the TLD zone is not found */ - private Zone getTldZoneByName() throws IOException { + private Zone getAndValidateTldZoneByName() throws IOException { // retrieve an existing TLD zone by name for (Zone zone : powerDnsClient.listZones()) { - if (zone.getName().equals(tldZoneName)) { + if (getSanitizedHostName(zone.getName()).equals(getSanitizedHostName(tldZoneName))) { + // validate the DNSSEC configuration and return the TLD zone + validateDnssecConfig(zone); return zone; } } - // Attempt to create a new TLD zone if it does not exist. The zone will have a - // basic SOA record, but will not have DNSSEC enabled. Adding DNSSEC is a follow - // up step using pdnsutil command line tool. + // attempt to create a new TLD zone if it does not exist try { - // base TLD zone object - logger.atInfo().log("Creating new PowerDNS TLD zone %s", tldZoneName); - Zone newTldZone = new Zone(); - newTldZone.setName(tldZoneName); - newTldZone.setKind(Zone.ZoneKind.Master); - - // create an initial SOA record, which may be modified later by an administrator - // or an automated onboarding process - RRSet soaRecord = new RRSet(); - soaRecord.setChangeType(RRSet.ChangeType.REPLACE); - soaRecord.setName(tldZoneName); - soaRecord.setTtl(3600); - soaRecord.setType("SOA"); - - // add content to the SOA record content from default configuration - RecordObject soaRecordContent = new RecordObject(); - soaRecordContent.setContent( - String.format( - "%s %s 1 900 1800 6048000 3600", powerDnsDefaultSoaMName, powerDnsDefaultSoaRName)); - soaRecordContent.setDisabled(false); - soaRecord.setRecords(new ArrayList(Arrays.asList(soaRecordContent))); - - // add the SOA record to the new TLD zone - newTldZone.setRrsets(new ArrayList(Arrays.asList(soaRecord))); - - // create the TLD zone and log the result - Zone createdTldZone = powerDnsClient.createZone(newTldZone); - logger.atInfo().log("Successfully created PowerDNS TLD zone %s", tldZoneName); - return createdTldZone; + // create a new TLD zone + Zone zone = createZone(); + + // configure DNSSEC and return the TLD zone + validateDnssecConfig(zone); + return zone; } catch (Exception e) { // log the error and continue logger.atWarning().log("Failed to create PowerDNS TLD zone %s: %s", tldZoneName, e); @@ -354,23 +566,47 @@ private Zone getTldZoneByName() throws IOException { /** * Get the TLD zone ID for the given TLD zone name from the cache, or compute it if it is not - * present in the cache. + * present in the cache. This method is synchronized since it may result in a new TLD zone being + * created and DNSSEC being configured, and this should only happen once. * * @return the ID of the TLD zone */ - private String getTldZoneId() { - return zoneIdCache.computeIfAbsent( - tldZoneName, - key -> { - try { - return getTldZoneByName().getId(); - } catch (Exception e) { - // TODO: throw this exception once PowerDNS is available, but for now we are just - // going to return a dummy ID - logger.atWarning().log("Failed to get PowerDNS TLD zone ID for %s: %s", tldZoneName, e); - return tldZoneName; - } - }); + private synchronized String getTldZoneId() throws IOException { + // clear the cache if it has expired + if (zoneIdCacheExpiration < System.currentTimeMillis()) { + logger.atInfo().log("Clearing PowerDNS TLD zone ID cache"); + zoneIdCache.clear(); + zoneIdCacheExpiration = System.currentTimeMillis() + 1000 * 60 * 60; // 1 hour + } + + // retrieve the TLD zone ID from the cache or retrieve it from the PowerDNS API + // if not available in the cache + String zoneId = + zoneIdCache.computeIfAbsent( + tldZoneName, + key -> { + try { + // retrieve the TLD zone by name, which may result from an existing zone or + // be dynamically created if the zone does not exist + Zone tldZone = getAndValidateTldZoneByName(); + + // return the TLD zone ID, which will be cached for the next hour + return tldZone.getId(); + } catch (IOException e) { + // log the error and return a null value to indicate failure + logger.atWarning().log( + "Failed to get PowerDNS TLD zone ID for %s: %s", tldZoneName, e); + return null; + } + }); + + // if the TLD zone ID is not found, throw an exception + if (zoneId == null) { + throw new IOException("TLD zone not found: " + tldZoneName); + } + + // return the TLD zone ID + return zoneId; } /** diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java index d185e56d75e..07bbff1e8bd 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -16,11 +16,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.flogger.FluentLogger; +import google.registry.dns.writer.powerdns.client.model.Cryptokey; +import google.registry.dns.writer.powerdns.client.model.Metadata; import google.registry.dns.writer.powerdns.client.model.Server; import google.registry.dns.writer.powerdns.client.model.Zone; import java.io.IOException; import java.util.List; import java.util.Objects; +import java.util.concurrent.TimeUnit; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -59,7 +62,11 @@ public PowerDNSClient(String baseUrl, String apiKey) { this.apiKey = apiKey; // initialize the base URL, API key, and HTTP client - this.httpClient = new OkHttpClient(); + this.httpClient = + new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(300, TimeUnit.SECONDS) + .build(); this.objectMapper = new ObjectMapper(); // initialize the Server ID @@ -119,6 +126,7 @@ private Response logAndExecuteRequest(Request request) throws IOException { return response; } + /** ZONE AND SERVER MANAGEMENT */ public List listServers() throws IOException { Request request = new Request.Builder().url(baseUrl + "/servers").header("X-API-Key", apiKey).get().build(); @@ -235,6 +243,24 @@ public void patchZone(Zone zone) throws IOException { } } + public void putZone(Zone zone) throws IOException { + String json = objectMapper.writeValueAsString(zone); + RequestBody body = RequestBody.create(json, MediaType.parse("application/json")); + + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zone.getId()) + .header("X-API-Key", apiKey) + .put(body) + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to patch zone: " + response); + } + } + } + public void notifyZone(String zoneId) throws IOException { Request request = new Request.Builder() @@ -249,4 +275,222 @@ public void notifyZone(String zoneId) throws IOException { } } } + + public void rectifyZone(String zoneId) throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId + "/rectify") + .header("X-API-Key", apiKey) + .put(RequestBody.create("", MediaType.parse("application/json"))) + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to notify zone: " + response); + } + } + } + + /** DNSSEC key management */ + public List listCryptokeys(String zoneId) throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId + "/cryptokeys") + .header("X-API-Key", apiKey) + .get() + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to list cryptokeys: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), + objectMapper.getTypeFactory().constructCollectionType(List.class, Cryptokey.class)); + } + } + + public Cryptokey createCryptokey(String zoneId, Cryptokey cryptokey) throws IOException { + String json = objectMapper.writeValueAsString(cryptokey); + RequestBody body = RequestBody.create(json, MediaType.parse("application/json")); + + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId + "/cryptokeys") + .header("X-API-Key", apiKey) + .post(body) + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to create cryptokey: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), Cryptokey.class); + } + } + + public Cryptokey getCryptokey(String zoneId, int cryptokeyId) throws IOException { + Request request = + new Request.Builder() + .url( + baseUrl + + "/servers/" + + serverId + + "/zones/" + + zoneId + + "/cryptokeys/" + + cryptokeyId) + .header("X-API-Key", apiKey) + .get() + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to get cryptokey: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), Cryptokey.class); + } + } + + public void modifyCryptokey(String zoneId, Cryptokey cryptokey) throws IOException { + String json = objectMapper.writeValueAsString(cryptokey); + RequestBody body = RequestBody.create(json, MediaType.parse("application/json")); + + Request request = + new Request.Builder() + .url( + baseUrl + + "/servers/" + + serverId + + "/zones/" + + zoneId + + "/cryptokeys/" + + cryptokey.getId()) + .header("X-API-Key", apiKey) + .put(body) + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to modify cryptokey: " + response); + } + } + } + + public void deleteCryptokey(String zoneId, int cryptokeyId) throws IOException { + Request request = + new Request.Builder() + .url( + baseUrl + + "/servers/" + + serverId + + "/zones/" + + zoneId + + "/cryptokeys/" + + cryptokeyId) + .header("X-API-Key", apiKey) + .delete() + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to delete cryptokey: " + response); + } + } + } + + /** ZONE METADATA MANAGEMENT */ + public List listMetadata(String zoneId) throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId + "/metadata") + .header("X-API-Key", apiKey) + .get() + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to list metadata: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), + objectMapper.getTypeFactory().constructCollectionType(List.class, Metadata.class)); + } + } + + public void createMetadata(String zoneId, Metadata metadata) throws IOException { + String json = objectMapper.writeValueAsString(metadata); + RequestBody body = RequestBody.create(json, MediaType.parse("application/json")); + + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId + "/metadata") + .header("X-API-Key", apiKey) + .post(body) + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to create metadata: " + response); + } + } + } + + public Metadata getMetadata(String zoneId, String metadataKind) throws IOException { + Request request = + new Request.Builder() + .url( + baseUrl + "/servers/" + serverId + "/zones/" + zoneId + "/metadata/" + metadataKind) + .header("X-API-Key", apiKey) + .get() + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to get metadata: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), Metadata.class); + } + } + + public Metadata modifyMetadata(String zoneId, String metadataKind, Metadata metadata) + throws IOException { + String json = objectMapper.writeValueAsString(metadata); + RequestBody body = RequestBody.create(json, MediaType.parse("application/json")); + + Request request = + new Request.Builder() + .url( + baseUrl + "/servers/" + serverId + "/zones/" + zoneId + "/metadata/" + metadataKind) + .header("X-API-Key", apiKey) + .put(body) + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to modify metadata: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), Metadata.class); + } + } + + public void deleteMetadata(String zoneId, String metadataKind) throws IOException { + Request request = + new Request.Builder() + .url( + baseUrl + "/servers/" + serverId + "/zones/" + zoneId + "/metadata/" + metadataKind) + .header("X-API-Key", apiKey) + .delete() + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to delete metadata: " + response); + } + } + } } diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Cryptokey.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Cryptokey.java new file mode 100644 index 00000000000..ddc4b4facda --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Cryptokey.java @@ -0,0 +1,168 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns.client.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class Cryptokey { + + public static Cryptokey createCryptokey( + KeyType keytype, int bits, Boolean active, Boolean published, String algorithm) { + Cryptokey k = new Cryptokey(); + k.setKeytype(keytype); + k.setBits(bits); + k.setAlgorithm(algorithm); + k.setActive(active); + k.setPublished(published); + return k; + } + + @JsonProperty("type") + private String type; + + @JsonProperty("id") + private Integer id; + + @JsonProperty("keytype") + private KeyType keytype; + + @JsonProperty("active") + private Boolean active; + + @JsonProperty("published") + private Boolean published; + + @JsonProperty("dnskey") + private String dnskey; + + @JsonProperty("ds") + private List ds; + + @JsonProperty("cds") + private List cds; + + @JsonProperty("privatekey") + private String privatekey; + + @JsonProperty("algorithm") + private String algorithm; + + @JsonProperty("bits") + private Integer bits; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public KeyType getKeytype() { + return keytype; + } + + public void setKeytype(KeyType keytype) { + this.keytype = keytype; + } + + public Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } + + public Boolean getPublished() { + return published; + } + + public void setPublished(Boolean published) { + this.published = published; + } + + public String getDnskey() { + return dnskey; + } + + public void setDnskey(String dnskey) { + this.dnskey = dnskey; + } + + public List getDs() { + return ds; + } + + public void setDs(List ds) { + this.ds = ds; + } + + public List getCds() { + return cds; + } + + public void setCds(List cds) { + this.cds = cds; + } + + public String getPrivatekey() { + return privatekey; + } + + public void setPrivatekey(String privatekey) { + this.privatekey = privatekey; + } + + public String getAlgorithm() { + return algorithm; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public Integer getBits() { + return bits; + } + + public void setBits(Integer bits) { + this.bits = bits; + } + + @Override + public String toString() { + return String.format( + "{id:%s,keytype:%s,active:%s,published:%s,algorithm:%s,bits:%s}", + id, keytype, active, published, algorithm, bits); + } + + public enum KeyType { + ksk, + zsk, + csk + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Metadata.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Metadata.java new file mode 100644 index 00000000000..1e17f2a81db --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Metadata.java @@ -0,0 +1,57 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns.client.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class Metadata { + + public static Metadata createMetadata(String kind, List metadata) { + Metadata m = new Metadata(); + m.setKind(kind); + m.setMetadata(metadata); + return m; + } + + @JsonProperty("kind") + private String kind; + + @JsonProperty("metadata") + private List metadata; + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public List getMetadata() { + return metadata; + } + + public void setMetadata(List metadata) { + this.metadata = metadata; + } + + @Override + public String toString() { + return String.format("{kind:%s,metadata:%s}", kind, metadata); + } +} From db534d36c68623eabdae2f4f100b2e1b18d96b95 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Thu, 15 May 2025 11:35:25 -0400 Subject: [PATCH 17/23] add license header to management example script --- .../dns/writer/powerdns/resources/management.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh b/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh index cfacb716ab7..c055d7760a3 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh +++ b/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh @@ -1,3 +1,17 @@ +# Copyright 2025 The Nomulus Authors. All Rights Reserved. +# +# Licensed 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. + ## Create a new zone # # The following commands are used to create a new zone in PowerDNS. From fa247e4a3b6bf5f3bb7d2e8bb29bbb2cfaef2deb Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Thu, 15 May 2025 16:30:21 -0400 Subject: [PATCH 18/23] log DS values for DNSSEC config --- .../dns/writer/powerdns/PowerDnsWriter.java | 58 ++++++++++++++----- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index d91ddbd4819..5f47f731b56 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -346,9 +346,11 @@ private void validateDnssecConfig(Zone zone) { logger.atInfo().log("Enabling DNSSEC for PowerDNS TLD zone %s", zone.getName()); // create the KSK and ZSK entries for the TLD zone - powerDnsClient.createCryptokey( - zone.getId(), - Cryptokey.createCryptokey(KeyType.ksk, DNSSEC_KSK_BITS, true, true, DNSSEC_ALGORITHM)); + Cryptokey newKsk = + powerDnsClient.createCryptokey( + zone.getId(), + Cryptokey.createCryptokey( + KeyType.ksk, DNSSEC_KSK_BITS, true, true, DNSSEC_ALGORITHM)); powerDnsClient.createCryptokey( zone.getId(), Cryptokey.createCryptokey(KeyType.zsk, DNSSEC_ZSK_BITS, true, true, DNSSEC_ALGORITHM)); @@ -377,12 +379,50 @@ private void validateDnssecConfig(Zone zone) { } // retrieve the zone and print the new DS values - logger.atInfo().log("Successfully enabled DNSSEC for PowerDNS TLD zone %s", zone.getName()); + logger.atInfo().log( + "Successfully enabled DNSSEC for PowerDNS TLD zone %s, DS=%s", + zone.getName(), + newKsk.getDs().stream() + .map(ds -> String.format("IN DS %s", ds)) + .collect(Collectors.toList())); } else { // DNSSEC is enabled, so we need to validate the configuration logger.atInfo().log( "Validating existing DNSSEC configuration for PowerDNS TLD zone %s", zone.getName()); + // list all crypto keys for the TLD zone + List cryptokeys = powerDnsClient.listCryptokeys(zone.getId()); + + // identify the KSK and ZSK records + Cryptokey activeZsk = + cryptokeys.stream() + .filter(c -> c.getActive() && c.getKeytype() == KeyType.zsk) + .findFirst() + .orElse(null); + Cryptokey activeKsk = + cryptokeys.stream() + .filter(c -> c.getActive() && c.getKeytype() == KeyType.ksk) + .findFirst() + .orElse(null); + + // validate the KSK and ZSK records are present + if (activeKsk == null || activeZsk == null) { + // log the error and continue + logger.atWarning().log( + "Unable to validate DNSSEC configuration with active KSK and ZSK records for PowerDNS" + + " TLD zone %s", + zone.getName()); + return; + } + + // log the DS records associated with the KSK record + logger.atInfo().log( + "Validated KSK and ZSK records for PowerDNS TLD zone %s, parent DS=%s", + zone.getName(), + activeKsk.getDs().stream() + .map(ds -> String.format("IN DS %s", ds)) + .collect(Collectors.toList())); + // check for a ZSK expiration flag if (zone.getAccount().contains(DNSSEC_ZSK_EXPIRE_FLAG)) { // check for an expired ZSK expiration date @@ -431,15 +471,7 @@ else if (zone.getAccount().contains(DNSSEC_ZSK_ACTIVATION_FLAG)) { "ZSK activation window has elapsed, activating ZSK for PowerDNS TLD zone %s", zone.getName()); - // list all crypto keys for the TLD zone - List cryptokeys = powerDnsClient.listCryptokeys(zone.getId()); - - // identify the active and inactive ZSKs - Cryptokey activeZsk = - cryptokeys.stream() - .filter(c -> c.getActive() && c.getKeytype() == KeyType.zsk) - .findFirst() - .orElse(null); + // identify the inactive ZSK Cryptokey inactiveZsk = cryptokeys.stream() .filter(c -> !c.getActive() && c.getKeytype() == KeyType.zsk) From cfc1b7fdebf8eb483f3b9275c46d109c00a97c91 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Thu, 15 May 2025 16:49:15 -0400 Subject: [PATCH 19/23] clarify DS logging entries --- .../google/registry/dns/writer/powerdns/PowerDnsWriter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 5f47f731b56..5307ec154fc 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -380,7 +380,7 @@ private void validateDnssecConfig(Zone zone) { // retrieve the zone and print the new DS values logger.atInfo().log( - "Successfully enabled DNSSEC for PowerDNS TLD zone %s, DS=%s", + "Successfully enabled DNSSEC for PowerDNS TLD zone %s, expected root DS=%s", zone.getName(), newKsk.getDs().stream() .map(ds -> String.format("IN DS %s", ds)) @@ -417,7 +417,7 @@ private void validateDnssecConfig(Zone zone) { // log the DS records associated with the KSK record logger.atInfo().log( - "Validated KSK and ZSK records for PowerDNS TLD zone %s, parent DS=%s", + "Validated KSK and ZSK records for PowerDNS TLD zone %s, expected root DS=%s", zone.getName(), activeKsk.getDs().stream() .map(ds -> String.format("IN DS %s", ds)) From 50ddac7b122953f89332b0a4350edfd7e028c703 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Fri, 16 May 2025 17:30:51 -0400 Subject: [PATCH 20/23] manage TSIG configuration --- .../dns/writer/powerdns/PowerDnsWriter.java | 78 +++++++++++++++- .../powerdns/client/PowerDNSClient.java | 72 +++++++++++++++ .../writer/powerdns/client/model/TSIGKey.java | 88 +++++++++++++++++++ .../writer/powerdns/resources/management.sh | 17 ++++ 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/google/registry/dns/writer/powerdns/client/model/TSIGKey.java diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 5307ec154fc..c7d95541abb 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -26,6 +26,7 @@ import google.registry.dns.writer.powerdns.client.model.Metadata; import google.registry.dns.writer.powerdns.client.model.RRSet; import google.registry.dns.writer.powerdns.client.model.RecordObject; +import google.registry.dns.writer.powerdns.client.model.TSIGKey; import google.registry.dns.writer.powerdns.client.model.Zone; import google.registry.util.Clock; import jakarta.inject.Inject; @@ -79,6 +80,10 @@ public class PowerDnsWriter extends DnsUpdateWriter { private static final String DNSSEC_ZSK_EXPIRE_FLAG = "DNSSEC-ZSK-EXPIRE-DATE"; private static final String DNSSEC_ZSK_ACTIVATION_FLAG = "DNSSEC-ZSK-ACTIVATION-DATE"; + // TSIG key configuration + private static final String TSIG_KEY_NAME = "axfr-key"; + private static final String TSIG_KEY_ALGORITHM = "hmac-sha256"; + /** * Class constructor. * @@ -313,8 +318,21 @@ private Zone createZone() throws IOException { soaRecordContent.setDisabled(false); soaRecord.setRecords(new ArrayList(Arrays.asList(soaRecordContent))); - // add the SOA record to the new TLD zone - newTldZone.setRrsets(new ArrayList(Arrays.asList(soaRecord))); + // create NS records, which may be modified later by an administrator + RRSet nsRecord = new RRSet(); + nsRecord.setChangeType(RRSet.ChangeType.REPLACE); + nsRecord.setName(tldZoneName); + nsRecord.setTtl(defaultZoneTtl); + nsRecord.setType("NS"); + + // add content to the NS record content from default configuration + RecordObject nsRecordContent = new RecordObject(); + nsRecordContent.setContent(defaultSoaMName); + nsRecordContent.setDisabled(false); + nsRecord.setRecords(new ArrayList(Arrays.asList(nsRecordContent))); + + // add the SOA and NS record to the new TLD zone + newTldZone.setRrsets(new ArrayList(Arrays.asList(soaRecord, nsRecord))); // create the TLD zone and log the result Zone createdTldZone = powerDnsClient.createZone(newTldZone); @@ -324,6 +342,56 @@ private Zone createZone() throws IOException { return createdTldZone; } + /** + * Validate the TSIG key configuration for the PowerDNS server. Ensures a TSIG key associated with + * the TLD zone is available for use, and detects whether the TLD zone has been configured to use + * the TSIG key during AXFR replication. Instructions are provided in the logs on how to configure + * both the primary and secondary DNS servers with the expected TSIG key. + * + * @param zone the TLD zone to validate + */ + private void validateTsigConfig(Zone zone) throws IOException { + // calculate the zone TSIG key name + logger.atInfo().log("Validating TSIG configuration for PowerDNS TLD zone %s", zone.getName()); + String zoneTsigKeyName = + String.format("%s-%s", TSIG_KEY_NAME, getSanitizedHostName(zone.getName())); + + // validate the named TSIG key is present in the PowerDNS server + try { + // check for existing TSIG key, which throws an exception if it is not found + powerDnsClient.getTSIGKey(zoneTsigKeyName); + } catch (Exception e) { + // create the TSIG key + logger.atInfo().log( + "Creating TSIG key '%s' for PowerDNS TLD zone %s", zoneTsigKeyName, zone.getName()); + powerDnsClient.createTSIGKey(TSIGKey.createTSIGKey(zoneTsigKeyName, TSIG_KEY_ALGORITHM)); + } + logger.atInfo().log( + "Validated TSIG key '%s' (%s) is available for AXFR replication to secondary servers for" + + " TLD zone %s. Retrieve the key using 'pdnsutil list-tsig-keys' in a secure" + + " environment and apply the key to the secondary server configuration.", + zoneTsigKeyName, TSIG_KEY_ALGORITHM, zone.getName()); + + // ensure the TSIG-ALLOW-AXFR metadata is set to the current TSIG key name + try { + Metadata metadata = powerDnsClient.getMetadata(zone.getId(), "TSIG-ALLOW-AXFR"); + // validate the metadata contains the expected TSIG key name + if (!metadata.getMetadata().contains(zoneTsigKeyName)) { + throw new IOException("missing expected TSIG-ALLOW-AXFR value"); + } + logger.atInfo().log( + "Validated PowerDNS TLD zone %s is ready for AXFR replication using TSIG key '%s'", + zone.getName(), zoneTsigKeyName); + } catch (IOException e) { + // log the missing metadata with instructions on how to configure it + logger.atSevere().log( + "PowerDNS TLD zone %s is not configured for AXFR replication using TSIG key '%s'." + + " Configure the replication using 'pdnsutil activate-tsig-key %s %s primary' in a" + + " secure environment.", + zoneTsigKeyName, zone.getName(), zone.getName(), zoneTsigKeyName); + } + } + /** * Validate the DNSSEC configuration for the TLD zone. If DNSSEC is not enabled, it will be * enabled and the KSK and ZSK entries will be created. If DNSSEC is enabled, the ZSK expiration @@ -573,6 +641,9 @@ private Zone getAndValidateTldZoneByName() throws IOException { // retrieve an existing TLD zone by name for (Zone zone : powerDnsClient.listZones()) { if (getSanitizedHostName(zone.getName()).equals(getSanitizedHostName(tldZoneName))) { + // validate the zone's TSIG key configuration + validateTsigConfig(zone); + // validate the DNSSEC configuration and return the TLD zone validateDnssecConfig(zone); return zone; @@ -584,6 +655,9 @@ private Zone getAndValidateTldZoneByName() throws IOException { // create a new TLD zone Zone zone = createZone(); + // validate the zone's TSIG key configuration + validateTsigConfig(zone); + // configure DNSSEC and return the TLD zone validateDnssecConfig(zone); return zone; diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java index 07bbff1e8bd..379d0926c1e 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -19,6 +19,7 @@ import google.registry.dns.writer.powerdns.client.model.Cryptokey; import google.registry.dns.writer.powerdns.client.model.Metadata; import google.registry.dns.writer.powerdns.client.model.Server; +import google.registry.dns.writer.powerdns.client.model.TSIGKey; import google.registry.dns.writer.powerdns.client.model.Zone; import java.io.IOException; import java.util.List; @@ -493,4 +494,75 @@ public void deleteMetadata(String zoneId, String metadataKind) throws IOExceptio } } } + + /** TSIG KEY MANAGEMENT */ + public List listTSIGKeys() throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/tsigkeys") + .header("X-API-Key", apiKey) + .get() + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to list TSIG keys: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), + objectMapper.getTypeFactory().constructCollectionType(List.class, TSIGKey.class)); + } + } + + public TSIGKey createTSIGKey(TSIGKey tsigKey) throws IOException { + String json = objectMapper.writeValueAsString(tsigKey); + RequestBody body = RequestBody.create(json, MediaType.parse("application/json")); + + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/tsigkeys") + .header("X-API-Key", apiKey) + .post(body) + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to create TSIG key: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), TSIGKey.class); + } + } + + public TSIGKey getTSIGKey(String tsigKeyId) throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/tsigkeys/" + tsigKeyId) + .header("X-API-Key", apiKey) + .get() + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to get TSIG key: " + response); + } + return objectMapper.readValue( + Objects.requireNonNull(response.body()).string(), TSIGKey.class); + } + } + + public void deleteTSIGKey(String tsigKeyId) throws IOException { + Request request = + new Request.Builder() + .url(baseUrl + "/servers/" + serverId + "/tsigkeys/" + tsigKeyId) + .header("X-API-Key", apiKey) + .delete() + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to delete TSIG key: " + response); + } + } + } } diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/client/model/TSIGKey.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/TSIGKey.java new file mode 100644 index 00000000000..ac4e5b6680b --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/TSIGKey.java @@ -0,0 +1,88 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.dns.writer.powerdns.client.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class TSIGKey { + @JsonProperty("name") + private String name; + + @JsonProperty("id") + private String id; + + @JsonProperty("algorithm") + private String algorithm; + + @JsonProperty("key") + private String key; + + @JsonProperty("type") + private String type; + + public static TSIGKey createTSIGKey(String name, String algorithm) { + TSIGKey tsigKey = new TSIGKey(); + tsigKey.setName(name); + tsigKey.setAlgorithm(algorithm); + return tsigKey; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAlgorithm() { + return algorithm; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + @Override + public String toString() { + return String.format("{name:%s,id:%s,algorithm:%s,type:%s}", name, id, algorithm, type); + } +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh b/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh index c055d7760a3..4c805f7a9f1 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh +++ b/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh @@ -118,3 +118,20 @@ dig DNSKEY $ZONE @127.0.0.1 # After propagation, the old key may be removed. ./pdnsutil remove-zone-key $ZONE OLD-KSK-ID + +## AXFR Replication Setup on the Primary PowerDNS Server +# +# The following commands are used to configure AXFR replication for a secondary +# PowerDNS server. + +# Assumes the TSIG was already created by PowerDnsWriter +./pdnsutil activate-tsig-key $ZONE axfr-key-$ZONE primary + +## AXFR Replication Setup on the Secondary PowerDNS Server +# +# The following commands are used to configure AXFR replication for a secondary +# PowerDNS server. + +./pdnsutil create-secondary-zone $ZONE $PRIMARY_IP:$PRIMARY_PORT +./pdnsutil import-tsig-key axfr-key-$ZONE hmac-sha256 $TSIG_SHARED_SECRET +./pdnsutil activate-tsig-key $ZONE axfr-key-$ZONE secondary From be6a2b0b1cf09a7ce20d00bea95fe47b809dddfd Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Fri, 16 May 2025 23:35:47 -0400 Subject: [PATCH 21/23] handle root NS server configuration for new TLDs --- .../registry/config/RegistryConfig.java | 34 ++++++------------- .../config/RegistryConfigSettings.java | 4 +-- .../registry/config/files/default-config.yaml | 6 ++-- .../dns/writer/powerdns/PowerDnsWriter.java | 30 ++++++++++------ 4 files changed, 35 insertions(+), 39 deletions(-) diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index 9a6cb10037a..cdca443adfd 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -134,50 +134,36 @@ public static String provideLocationId(RegistryConfigSettings config) { @Provides @Config("powerDnsBaseUrl") public static String providePowerDnsBaseUrl(RegistryConfigSettings config) { - if (config.powerDns.baseUrl != null) { - return config.powerDns.baseUrl; - } - return "http://localhost:8081/api/v1"; + return config.powerDns.baseUrl; } /** API key for the PowerDNS server. */ @Provides @Config("powerDnsApiKey") public static String providePowerDnsApiKey(RegistryConfigSettings config) { - if (config.powerDns.apiKey != null) { - return config.powerDns.apiKey; - } - return "example-api-key"; + return config.powerDns.apiKey; } /** Whether DNSSEC is enabled for the PowerDNS server. */ @Provides @Config("powerDnsDnssecEnabled") public static Boolean providePowerDnsDnssecEnabled(RegistryConfigSettings config) { - if (config.powerDns.dnssecEnabled != null) { - return config.powerDns.dnssecEnabled; - } - return false; + return config.powerDns.dnssecEnabled; } /** Default SOA MNAME for the TLD zone. */ @Provides - @Config("powerDnsDefaultSoaMName") - public static String providePowerDnsDefaultSoaMName(RegistryConfigSettings config) { - if (config.powerDns.defaultSoaMName != null) { - return config.powerDns.defaultSoaMName; - } - return "a.gtld-servers.net."; + @Config("powerDnsRootNameServers") + public static ImmutableList providePowerDnsRootNameServers( + RegistryConfigSettings config) { + return ImmutableList.copyOf(config.powerDns.rootNameServers); } /** Default SOA RNAME for the TLD zone. */ @Provides - @Config("powerDnsDefaultSoaRName") - public static String providePowerDnsDefaultSoaRName(RegistryConfigSettings config) { - if (config.powerDns.defaultSoaRName != null) { - return config.powerDns.defaultSoaRName; - } - return "nstld.verisign-grs.com."; + @Config("powerDnsSoaName") + public static String providePowerDnsSoaName(RegistryConfigSettings config) { + return config.powerDns.soaName; } /** diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index dd1d6c841ac..9e27de3e9d5 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -64,8 +64,8 @@ public static class PowerDns { public String baseUrl; public String apiKey; public Boolean dnssecEnabled; - public String defaultSoaMName; - public String defaultSoaRName; + public List rootNameServers; + public String soaName; } /** Configuration options for authenticating users. */ diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 16796d267d0..4b1dadf8daf 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -31,8 +31,10 @@ powerDns: baseUrl: http://localhost:8081/api/v1 apiKey: example-api-key dnssecEnabled: false - defaultSoaMName: a.example.com. - defaultSoaRName: nstld.example.com. + rootNameServers: + - ns1.example.com. + - ns2.example.com. + soaName: nstld.example.com. gSuite: # Publicly accessible domain name of the running G Suite instance. diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index c7d95541abb..83f430dd65f 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -15,6 +15,7 @@ package google.registry.dns.writer.powerdns; import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.flogger.FluentLogger; import google.registry.config.RegistryConfig.Config; @@ -55,8 +56,8 @@ public class PowerDnsWriter extends DnsUpdateWriter { // PowerDNS configuration private final String tldZoneName; - private final String defaultSoaMName; - private final String defaultSoaRName; + private final ImmutableList rootNameServers; + private final String soaName; private final Boolean dnssecEnabled; private final PowerDNSClient powerDnsClient; @@ -103,8 +104,8 @@ public PowerDnsWriter( @Config("dnsDefaultDsTtl") Duration dnsDefaultDsTtl, @Config("powerDnsBaseUrl") String powerDnsBaseUrl, @Config("powerDnsApiKey") String powerDnsApiKey, - @Config("powerDnsDefaultSoaMName") String powerDnsDefaultSoaMName, - @Config("powerDnsDefaultSoaRName") String powerDnsDefaultSoaRName, + @Config("powerDnsRootNameServers") ImmutableList powerDnsRootNameServers, + @Config("powerDnsSoaName") String powerDnsSoaName, @Config("powerDnsDnssecEnabled") Boolean powerDnsDnssecEnabled, Clock clock) { @@ -114,8 +115,8 @@ public PowerDnsWriter( // Initialize the PowerDNS client this.tldZoneName = getCanonicalHostName(tldZoneName); - this.defaultSoaMName = powerDnsDefaultSoaMName; - this.defaultSoaRName = powerDnsDefaultSoaRName; + this.rootNameServers = powerDnsRootNameServers; + this.soaName = powerDnsSoaName; this.dnssecEnabled = powerDnsDnssecEnabled; this.powerDnsClient = new PowerDNSClient(powerDnsBaseUrl, powerDnsApiKey); } @@ -314,7 +315,7 @@ private Zone createZone() throws IOException { RecordObject soaRecordContent = new RecordObject(); soaRecordContent.setContent( String.format( - "%s %s 1 900 1800 6048000 %s", defaultSoaMName, defaultSoaRName, defaultZoneTtl)); + "%s %s 1 900 1800 6048000 %s", rootNameServers.get(0), soaName, defaultZoneTtl)); soaRecordContent.setDisabled(false); soaRecord.setRecords(new ArrayList(Arrays.asList(soaRecordContent))); @@ -326,10 +327,17 @@ private Zone createZone() throws IOException { nsRecord.setType("NS"); // add content to the NS record content from default configuration - RecordObject nsRecordContent = new RecordObject(); - nsRecordContent.setContent(defaultSoaMName); - nsRecordContent.setDisabled(false); - nsRecord.setRecords(new ArrayList(Arrays.asList(nsRecordContent))); + nsRecord.setRecords( + new ArrayList( + rootNameServers.stream() + .map( + ns -> { + RecordObject nsRecordContent = new RecordObject(); + nsRecordContent.setContent(ns); + nsRecordContent.setDisabled(false); + return nsRecordContent; + }) + .collect(Collectors.toList()))); // add the SOA and NS record to the new TLD zone newTldZone.setRrsets(new ArrayList(Arrays.asList(soaRecord, nsRecord))); From 765454b1f8007eee78f3b40f0042f7f7ed5dc93f Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Mon, 19 May 2025 10:13:28 -0400 Subject: [PATCH 22/23] optional TSIG configuration flag --- .../google/registry/config/RegistryConfig.java | 7 +++++++ .../registry/config/RegistryConfigSettings.java | 1 + .../registry/config/files/default-config.yaml | 1 + .../dns/writer/powerdns/PowerDnsWriter.java | 14 ++++++++++++-- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index cdca443adfd..4e779bd9ef9 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -151,6 +151,13 @@ public static Boolean providePowerDnsDnssecEnabled(RegistryConfigSettings config return config.powerDns.dnssecEnabled; } + /** Whether TSIG is enabled for the PowerDNS server. */ + @Provides + @Config("powerDnsTsigEnabled") + public static Boolean providePowerDnsTsigEnabled(RegistryConfigSettings config) { + return config.powerDns.tsigEnabled; + } + /** Default SOA MNAME for the TLD zone. */ @Provides @Config("powerDnsRootNameServers") diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 9e27de3e9d5..2d139db779d 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -64,6 +64,7 @@ public static class PowerDns { public String baseUrl; public String apiKey; public Boolean dnssecEnabled; + public Boolean tsigEnabled; public List rootNameServers; public String soaName; } diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 4b1dadf8daf..43626bcd3f9 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -31,6 +31,7 @@ powerDns: baseUrl: http://localhost:8081/api/v1 apiKey: example-api-key dnssecEnabled: false + tsigEnabled: true rootNameServers: - ns1.example.com. - ns2.example.com. diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java index 83f430dd65f..ddb18603738 100644 --- a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -59,6 +59,7 @@ public class PowerDnsWriter extends DnsUpdateWriter { private final ImmutableList rootNameServers; private final String soaName; private final Boolean dnssecEnabled; + private final Boolean tsigEnabled; private final PowerDNSClient powerDnsClient; // Supported record types to synchronize with PowerDNS @@ -82,7 +83,7 @@ public class PowerDnsWriter extends DnsUpdateWriter { private static final String DNSSEC_ZSK_ACTIVATION_FLAG = "DNSSEC-ZSK-ACTIVATION-DATE"; // TSIG key configuration - private static final String TSIG_KEY_NAME = "axfr-key"; + private static final String TSIG_KEY_NAME = "tsig"; private static final String TSIG_KEY_ALGORITHM = "hmac-sha256"; /** @@ -107,6 +108,7 @@ public PowerDnsWriter( @Config("powerDnsRootNameServers") ImmutableList powerDnsRootNameServers, @Config("powerDnsSoaName") String powerDnsSoaName, @Config("powerDnsDnssecEnabled") Boolean powerDnsDnssecEnabled, + @Config("powerDnsTsigEnabled") Boolean powerDnsTsigEnabled, Clock clock) { // call the DnsUpdateWriter constructor, omitting the transport parameter @@ -118,6 +120,7 @@ public PowerDnsWriter( this.rootNameServers = powerDnsRootNameServers; this.soaName = powerDnsSoaName; this.dnssecEnabled = powerDnsDnssecEnabled; + this.tsigEnabled = powerDnsTsigEnabled; this.powerDnsClient = new PowerDNSClient(powerDnsBaseUrl, powerDnsApiKey); } @@ -359,10 +362,17 @@ private Zone createZone() throws IOException { * @param zone the TLD zone to validate */ private void validateTsigConfig(Zone zone) throws IOException { + // check if TSIG configuration is required + if (!tsigEnabled) { + logger.atInfo().log( + "TSIG validation is not required for PowerDNS TLD zone %s", zone.getName()); + return; + } + // calculate the zone TSIG key name logger.atInfo().log("Validating TSIG configuration for PowerDNS TLD zone %s", zone.getName()); String zoneTsigKeyName = - String.format("%s-%s", TSIG_KEY_NAME, getSanitizedHostName(zone.getName())); + String.format("%s-%s", getSanitizedHostName(zone.getName()), TSIG_KEY_NAME); // validate the named TSIG key is present in the PowerDNS server try { From 4187a4901ebe928c33798dde697bbb613f13b483 Mon Sep 17 00:00:00 2001 From: Aaron Quirk Date: Fri, 23 May 2025 13:31:38 -0400 Subject: [PATCH 23/23] Update console server for environment-aware assets and dispatch rules --- console-webapp/staged/dispatch.yaml | 13 ++++++++++ console-webapp/staged/server.js | 37 ++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 console-webapp/staged/dispatch.yaml diff --git a/console-webapp/staged/dispatch.yaml b/console-webapp/staged/dispatch.yaml new file mode 100644 index 00000000000..81ddb1bcf80 --- /dev/null +++ b/console-webapp/staged/dispatch.yaml @@ -0,0 +1,13 @@ +dispatch: + # Route console-api requests to the default service + - url: "*/console-api/*" + service: default + + # Route console requests to the console service + - url: "*/console/*" + service: console + + # Requests to the root domain also go to the console service + - url: "*/" + service: console + diff --git a/console-webapp/staged/server.js b/console-webapp/staged/server.js index e2894d0b6f9..ca9cff1838f 100644 --- a/console-webapp/staged/server.js +++ b/console-webapp/staged/server.js @@ -13,22 +13,47 @@ // limitations under the License. const express = require('express'); +const path = require('path'); +const fs = require('fs'); const app = express(); const PORT = process.env.PORT || 8080; -app.use("/console", express.static('dist', { +// Determine which environment's directory to use +const environment = process.env.GAE_ENV === 'standard' ? 'console-alpha' : 'dist'; + +function setCustomCacheControl(res, path) { + // Custom Cache-Control for HTML files - we don't want to cache them + if (express.static.mime.lookup(path) === 'text/html') { + res.setHeader('Cache-Control', 'public, max-age=0'); + } +} + +// Serve static files at root +app.use(express.static(environment, { etag: false, lastModified: false, maxAge: '1d', setHeaders: setCustomCacheControl })); -function setCustomCacheControl (res, path) { - // Custom Cache-Control for HTML files - we don't want to cache them - if (express.static.mime.lookup(path) === 'text/html') { - res.setHeader('Cache-Control', 'public, max-age=0'); +// Serve static files at /console +app.use('/console', express.static(environment, { + etag: false, + lastModified: false, + maxAge: '1d', + setHeaders: setCustomCacheControl +})); + +// For SPA routing - redirect all other routes to index.html +app.get('*', (req, res) => { + const indexPath = path.join(environment, 'index.html'); + if (fs.existsSync(indexPath)) { + res.sendFile(path.resolve(indexPath)); + } else { + res.status(404).send(`Cannot find ${indexPath}`); } -} +}); app.listen(PORT); +