diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index f5421975d10..59003b173d5 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -72,8 +72,8 @@ public final class RegistryConfig { public static final String CANARY_HEADER = "canary"; - private static final String ENVIRONMENT_CONFIG_FORMAT = "files/nomulus-config-%s.yaml"; - private static final String YAML_CONFIG_PROD = + private static final String YAML_CONFIG_ENV_TEMPLATE = "files/nomulus-config-%s.yaml"; + private static final String YAML_CONFIG_DEFAULT = readResourceUtf8(RegistryConfig.class, "files/default-config.yaml"); /** Dagger qualifier for configuration settings. */ @@ -85,19 +85,27 @@ public final class RegistryConfig { } /** - * Loads the {@link RegistryConfigSettings} POJO from the YAML configuration files. + * Loads a generic typed POJO from the YAML configuration files. * - *

The {@code default-config.yaml} file in this directory is loaded first, and a fatal error is - * thrown if it cannot be found or if there is an error parsing it. Separately, the - * environment-specific config file named {@code nomulus-config-ENVIRONMENT.yaml} is also loaded - * and those values merged into the POJO. + *

The {@code defaultYaml} file is loaded first, and a fatal error is thrown if it cannot be + * found or if there is an error parsing it. Separately, the environment-specific config file + * template {@code customYamlTemplate} is also loaded and those values merged into the POJO. */ - static RegistryConfigSettings getConfigSettings() { + public static T getEnvironmentConfigSettings( + String defaultYaml, String customYamlTemplate, Class clazz) { String configFilePath = - String.format( - ENVIRONMENT_CONFIG_FORMAT, Ascii.toLowerCase(RegistryEnvironment.get().name())); + String.format(customYamlTemplate, Ascii.toLowerCase(RegistryEnvironment.get().name())); String customYaml = readResourceUtf8(RegistryConfig.class, configFilePath); - return YamlUtils.getConfigSettings(YAML_CONFIG_PROD, customYaml, RegistryConfigSettings.class); + return YamlUtils.getConfigSettings(defaultYaml, customYaml, clazz); + } + + /** + * Shorthand method to retrieve the main registry config settings for the current environment. Can + * be changed to public scope if needed by other packages. + */ + private static RegistryConfigSettings getConfigSettings() { + return RegistryConfig.getEnvironmentConfigSettings( + YAML_CONFIG_DEFAULT, YAML_CONFIG_ENV_TEMPLATE, RegistryConfigSettings.class); } /** Dagger module for providing configuration settings. */ diff --git a/core/src/main/java/google/registry/config/files/power-dns/default.yaml b/core/src/main/java/google/registry/config/files/power-dns/default.yaml new file mode 100644 index 00000000000..f2328fb16fc --- /dev/null +++ b/core/src/main/java/google/registry/config/files/power-dns/default.yaml @@ -0,0 +1,10 @@ +# Default configuration for PowerDNS +powerDns: + baseUrl: http://localhost:8081/api/v1 + apiKey: example-api-key + dnssecEnabled: false + tsigEnabled: true + rootNameServers: + - ns1.example.com. + - ns2.example.com. + soaName: nstld.example.com. diff --git a/core/src/main/java/google/registry/config/files/power-dns/env-alpha.yaml b/core/src/main/java/google/registry/config/files/power-dns/env-alpha.yaml new file mode 100644 index 00000000000..15350061751 --- /dev/null +++ b/core/src/main/java/google/registry/config/files/power-dns/env-alpha.yaml @@ -0,0 +1 @@ +# Add environment-specific configuration here. \ No newline at end of file diff --git a/core/src/main/java/google/registry/config/files/power-dns/env-crash.yaml b/core/src/main/java/google/registry/config/files/power-dns/env-crash.yaml new file mode 100644 index 00000000000..15350061751 --- /dev/null +++ b/core/src/main/java/google/registry/config/files/power-dns/env-crash.yaml @@ -0,0 +1 @@ +# Add environment-specific configuration here. \ No newline at end of file diff --git a/core/src/main/java/google/registry/config/files/power-dns/env-local.yaml b/core/src/main/java/google/registry/config/files/power-dns/env-local.yaml new file mode 100644 index 00000000000..15350061751 --- /dev/null +++ b/core/src/main/java/google/registry/config/files/power-dns/env-local.yaml @@ -0,0 +1 @@ +# Add environment-specific configuration here. \ No newline at end of file diff --git a/core/src/main/java/google/registry/config/files/power-dns/env-production.yaml b/core/src/main/java/google/registry/config/files/power-dns/env-production.yaml new file mode 100644 index 00000000000..15350061751 --- /dev/null +++ b/core/src/main/java/google/registry/config/files/power-dns/env-production.yaml @@ -0,0 +1 @@ +# Add environment-specific configuration here. \ No newline at end of file diff --git a/core/src/main/java/google/registry/config/files/power-dns/env-qa.yaml b/core/src/main/java/google/registry/config/files/power-dns/env-qa.yaml new file mode 100644 index 00000000000..15350061751 --- /dev/null +++ b/core/src/main/java/google/registry/config/files/power-dns/env-qa.yaml @@ -0,0 +1 @@ +# Add environment-specific configuration here. \ No newline at end of file diff --git a/core/src/main/java/google/registry/config/files/power-dns/env-sandbox.yaml b/core/src/main/java/google/registry/config/files/power-dns/env-sandbox.yaml new file mode 100644 index 00000000000..15350061751 --- /dev/null +++ b/core/src/main/java/google/registry/config/files/power-dns/env-sandbox.yaml @@ -0,0 +1 @@ +# Add environment-specific configuration here. \ No newline at end of file diff --git a/core/src/main/java/google/registry/config/files/power-dns/env-unittest.yaml b/core/src/main/java/google/registry/config/files/power-dns/env-unittest.yaml new file mode 100644 index 00000000000..f42d029b031 --- /dev/null +++ b/core/src/main/java/google/registry/config/files/power-dns/env-unittest.yaml @@ -0,0 +1,10 @@ +# Add environment-specific configuration here. +powerDns: + baseUrl: http://unittest:8081/api/v1 + apiKey: unittest-api-key + dnssecEnabled: true + tsigEnabled: true + rootNameServers: + - ns1.unittest.com. + - ns2.unittest.com. + soaName: nstld.unittest.com. diff --git a/core/src/main/java/google/registry/dns/writer/DnsWritersModule.java b/core/src/main/java/google/registry/dns/writer/DnsWritersModule.java index e25f5837d51..4289e416d22 100644 --- a/core/src/main/java/google/registry/dns/writer/DnsWritersModule.java +++ b/core/src/main/java/google/registry/dns/writer/DnsWritersModule.java @@ -17,6 +17,7 @@ import dagger.Module; import google.registry.dns.writer.clouddns.CloudDnsWriterModule; import google.registry.dns.writer.dnsupdate.DnsUpdateWriterModule; +import google.registry.dns.writer.powerdns.PowerDnsWriterModule; /** * Groups all {@link DnsWriter} implementations to be installed. @@ -25,5 +26,10 @@ * the release process. */ @Module( - includes = {CloudDnsWriterModule.class, DnsUpdateWriterModule.class, VoidDnsWriterModule.class}) + includes = { + CloudDnsWriterModule.class, + DnsUpdateWriterModule.class, + VoidDnsWriterModule.class, + PowerDnsWriterModule.class + }) public class DnsWritersModule {} 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/PowerDnsConfig.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfig.java new file mode 100644 index 00000000000..6eb0d89c80b --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfig.java @@ -0,0 +1,113 @@ +// 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 static com.google.common.base.Suppliers.memoize; +import static google.registry.util.ResourceUtils.readResourceUtf8; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import dagger.Module; +import dagger.Provides; +import google.registry.config.RegistryConfig; +import google.registry.config.RegistryConfig.Config; +import jakarta.inject.Singleton; +import java.util.function.Supplier; + +/** + * Configuration manager for PowerDNS settings. + * + *

This class is responsible for loading the PowerDNS configuration from the YAML files, found in + * the {@code files/power-dns} directory. + */ +public final class PowerDnsConfig { + + // expected config file locations for PowerDNS + private static final String YAML_CONFIG_ENV_TEMPLATE = "files/power-dns/env-%s.yaml"; + private static final String YAML_CONFIG_DEFAULT = + readResourceUtf8(RegistryConfig.class, "files/power-dns/default.yaml"); + + /** Dagger module that provides DNS configuration settings. */ + @Module + public static final class PowerDnsConfigModule { + + /** Parsed PowerDNS configuration settings. */ + @Singleton + @Provides + static PowerDnsConfigSettings providePowerDnsConfigSettings() { + return CONFIG_SETTINGS.get(); + } + + /** Base URL of the PowerDNS server. */ + @Provides + @Config("powerDnsBaseUrl") + public static String providePowerDnsBaseUrl(PowerDnsConfigSettings config) { + return config.powerDns.baseUrl; + } + + /** API key for the PowerDNS server. */ + @Provides + @Config("powerDnsApiKey") + public static String providePowerDnsApiKey(PowerDnsConfigSettings config) { + return config.powerDns.apiKey; + } + + /** Whether DNSSEC is enabled for the PowerDNS server. */ + @Provides + @Config("powerDnsDnssecEnabled") + public static Boolean providePowerDnsDnssecEnabled(PowerDnsConfigSettings config) { + return config.powerDns.dnssecEnabled; + } + + /** Whether TSIG is enabled for the PowerDNS server. */ + @Provides + @Config("powerDnsTsigEnabled") + public static Boolean providePowerDnsTsigEnabled(PowerDnsConfigSettings config) { + return config.powerDns.tsigEnabled; + } + + /** Default SOA MNAME for the TLD zone. */ + @Provides + @Config("powerDnsRootNameServers") + public static ImmutableList providePowerDnsRootNameServers( + PowerDnsConfigSettings config) { + return ImmutableList.copyOf(config.powerDns.rootNameServers); + } + + /** Default SOA RNAME for the TLD zone. */ + @Provides + @Config("powerDnsSoaName") + public static String providePowerDnsSoaName(PowerDnsConfigSettings config) { + return config.powerDns.soaName; + } + + private PowerDnsConfigModule() {} + } + + /** + * Memoizes loading of the {@link PowerDnsConfigSettings} POJO. + * + *

Memoizing without cache expiration is used because the app must be re-deployed in order to + * change the contents of the YAML config files. + */ + @VisibleForTesting + public static final Supplier CONFIG_SETTINGS = + memoize( + () -> + RegistryConfig.getEnvironmentConfigSettings( + YAML_CONFIG_DEFAULT, YAML_CONFIG_ENV_TEMPLATE, PowerDnsConfigSettings.class)); + + private PowerDnsConfig() {} +} diff --git a/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigSettings.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigSettings.java new file mode 100644 index 00000000000..5b14a9e026f --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsConfigSettings.java @@ -0,0 +1,32 @@ +// 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 java.util.List; + +/** The POJO that PowerDNS YAML config files are deserialized into. */ +public class PowerDnsConfigSettings { + + public PowerDns powerDns; + + 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/dns/writer/powerdns/PowerDnsWriter.java b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java new file mode 100644 index 00000000000..fac8fc59050 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriter.java @@ -0,0 +1,909 @@ +// 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.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; +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.TSIGKey; +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 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; +import org.xbill.DNS.Type; +import org.xbill.DNS.Update; + +/** + * 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 { + // Class configuration + public static final String NAME = "PowerDnsWriter"; + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + // PowerDNS configuration + private final String tldZoneName; + 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 + 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"; + + // TSIG key configuration + private static final String TSIG_KEY_NAME = "tsig"; + private static final String TSIG_KEY_ALGORITHM = "hmac-sha256"; + + /** + * Class constructor. + * + * @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 + * @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 + */ + @Inject + public PowerDnsWriter( + @DnsWriterZone String tldZoneName, + @Config("dnsDefaultATtl") Duration dnsDefaultATtl, + @Config("dnsDefaultNsTtl") Duration dnsDefaultNsTtl, + @Config("dnsDefaultDsTtl") Duration dnsDefaultDsTtl, + @Config("powerDnsBaseUrl") String powerDnsBaseUrl, + @Config("powerDnsApiKey") String powerDnsApiKey, + @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 + // since we don't need it for PowerDNS + super(tldZoneName, dnsDefaultATtl, dnsDefaultNsTtl, dnsDefaultDsTtl, null, clock); + + // Initialize the PowerDNS client + this.tldZoneName = getHostNameWithTrailingDot(tldZoneName); + this.rootNameServers = powerDnsRootNameServers; + this.soaName = powerDnsSoaName; + this.dnssecEnabled = powerDnsDnssecEnabled; + this.tsigEnabled = powerDnsTsigEnabled; + this.powerDnsClient = new PowerDNSClient(powerDnsBaseUrl, powerDnsApiKey); + } + + /** + * 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) { + String normalizedDomainName = getHostNameWithoutTrailingDot(domainName); + logger.atInfo().log("Staging domain %s for PowerDNS", normalizedDomainName); + super.publishDomain(normalizedDomainName); + } + + /** + * 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) { + String normalizedHostName = getHostNameWithoutTrailingDot(hostName); + logger.atInfo().log("Staging host %s for PowerDNS", normalizedHostName); + super.publishHost(normalizedHostName); + } + + @Override + protected void commitUnchecked() { + try { + // persist staged changes to PowerDNS + logger.atInfo().log("Committing updates to PowerDNS for TLD %s", tldZoneName); + + // convert the update to a PowerDNS Zone object + Zone zone = convertUpdateToZone(update); + + // 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); + } + } + + /** + * 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 { + // 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 : 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())) + && ((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())); + + // handle record updates and deletions + if (isDelete) { + // indicate that this is a record deletion + 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); + + // append the record to the RRSet + rrSet.getRecords().add(recordObject); + } + } + + // 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( + "Successfully processed PowerDNS TLD zone %s update record: %s", + tldZoneName, preparedTldZone); + 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(getHostNameWithTrailingDot(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; + } + + /** + * 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(getHostNameWithTrailingDot(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(getHostNameWithTrailingDot(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", + getHostNameWithTrailingDot(rootNameServers.get(0)), + getHostNameWithTrailingDot(soaName), + defaultZoneTtl)); + soaRecordContent.setDisabled(false); + soaRecord.setRecords(new ArrayList(Arrays.asList(soaRecordContent))); + + // create NS records, which may be modified later by an administrator + RRSet nsRecord = new RRSet(); + nsRecord.setChangeType(RRSet.ChangeType.REPLACE); + nsRecord.setName(getHostNameWithTrailingDot(tldZoneName)); + nsRecord.setTtl(defaultZoneTtl); + nsRecord.setType("NS"); + + // add content to the NS record content from default configuration + nsRecord.setRecords( + new ArrayList( + rootNameServers.stream() + .map( + ns -> { + RecordObject nsRecordContent = new RecordObject(); + nsRecordContent.setContent(getHostNameWithTrailingDot(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))); + + // 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 and synchronize zone configuration for the provided TLD zone. includes SOA, NS, TSIG, + * and DNSSEC configuration. + * + * @param zone the TLD zone to validate + */ + private void validateZoneConfig(Zone zone) throws IOException { + // validate the SOA and root NS records + validateSoaConfig(zone); + + // validate the NS records + validateNsConfig(zone); + + // validate the TSIG key configuration + validateTsigConfig(zone); + + // validate the DNSSEC configuration + validateDnssecConfig(zone); + } + + /** + * Validate the SOA record for the TLD zone. + * + * @param zone the TLD zone to validate + */ + private void validateSoaConfig(Zone zone) throws IOException { + // retrieve the existing SOA record + logger.atInfo().log("Validating SOA record for PowerDNS TLD zone %s", zone.getName()); + RRSet soaRecord = + zone.getRrsets().stream() + .filter(rrset -> rrset.getType().equals("SOA")) + .findFirst() + .orElse(null); + if (soaRecord == null || soaRecord.getRecords() == null) { + throw new IOException("Invalid SOA record state for PowerDNS TLD zone " + zone.getName()); + } + + // retrieve the SOA record RRSet content exists + RecordObject soaRecordContent = soaRecord.getRecords().get(0); + if (soaRecordContent == null || soaRecordContent.getContent() == null) { + throw new IOException( + "Invalid SOA record content state for PowerDNS TLD zone " + zone.getName()); + } + + // validate the SOA record content exists + String soaRecordContentString = soaRecordContent.getContent(); + if (soaRecordContentString == null) { + throw new IOException( + "Invalid SOA record content data for PowerDNS TLD zone " + zone.getName()); + } + + // validate the SOA string starts with the first root name server and the SOA contact + // name found in the registry configuration + if (soaRecordContentString.startsWith( + String.format( + "%s %s ", + getHostNameWithTrailingDot(rootNameServers.get(0)), + getHostNameWithTrailingDot(soaName)))) { + logger.atInfo().log( + "Successfully validated SOA record for PowerDNS TLD zone %s", zone.getName()); + return; + } + + // update the SOA record to the expected value + logger.atWarning().log("Updating SOA record for PowerDNS TLD zone %s", zone.getName()); + RRSet newSoaRecord = new RRSet(); + newSoaRecord.setChangeType(RRSet.ChangeType.REPLACE); + newSoaRecord.setName(getHostNameWithTrailingDot(zone.getName())); + newSoaRecord.setTtl(defaultZoneTtl); + newSoaRecord.setType("SOA"); + + // add content to the SOA record content from default configuration + RecordObject newSoaRecordContent = new RecordObject(); + newSoaRecordContent.setContent( + String.format( + "%s %s %s 900 1800 6048000 %s", + getHostNameWithTrailingDot(rootNameServers.get(0)), + getHostNameWithTrailingDot(soaName), + zone.getSerial(), + defaultZoneTtl)); + newSoaRecordContent.setDisabled(false); + newSoaRecord.setRecords(new ArrayList(Arrays.asList(newSoaRecordContent))); + + // add the SOA to the TLD zone + zone.setRrsets(new ArrayList(Arrays.asList(newSoaRecord))); + + // call the PowerDNS API to commit the changes + powerDnsClient.patchZone(zone); + logger.atInfo().log("Successfully updated SOA record for PowerDNS TLD zone %s", zone.getName()); + } + + /** + * Validate the NS records for the TLD zone. + * + * @param zone the TLD zone to validate + */ + private void validateNsConfig(Zone zone) throws IOException { + // retrieve the existing NS records + logger.atInfo().log("Validating NS records for PowerDNS TLD zone %s", zone.getName()); + RRSet nsRecord = + zone.getRrsets().stream() + .filter( + rrset -> + rrset.getType().equals("NS") + && getHostNameWithoutTrailingDot(rrset.getName()) + .equals(getHostNameWithoutTrailingDot(zone.getName()))) + .findFirst() + .orElse(null); + if (nsRecord == null || nsRecord.getRecords() == null) { + throw new IOException("Invalid NS record state for PowerDNS TLD zone " + zone.getName()); + } + + // retrieve normalized list of existing NS record content + List existingNsRecords = + nsRecord.getRecords().stream() + .map(r -> getHostNameWithoutTrailingDot(r.getContent())) + .collect(Collectors.toList()); + + // make normalized list of expected NS record content + List expectedNsRecords = + rootNameServers.stream() + .map(r -> getHostNameWithoutTrailingDot(r)) + .collect(Collectors.toList()); + + // validate the existing NS record array elements match the expected elements + // found in the root name servers list + if (existingNsRecords.equals(expectedNsRecords)) { + logger.atInfo().log( + "Successfully validated NS records for PowerDNS TLD zone %s", zone.getName()); + return; + } + + // update the NS records to the expected value + logger.atWarning().log( + "Updating NS records for PowerDNS TLD zone %s. Existing=%s, New=%s", + zone.getName(), existingNsRecords, expectedNsRecords); + RRSet newNsRecord = new RRSet(); + newNsRecord.setChangeType(RRSet.ChangeType.REPLACE); + newNsRecord.setName(getHostNameWithTrailingDot(zone.getName())); + newNsRecord.setTtl(defaultZoneTtl); + newNsRecord.setType("NS"); + + // add content to the NS record content from default configuration + newNsRecord.setRecords( + new ArrayList( + rootNameServers.stream() + .map( + ns -> { + RecordObject nsRecordContent = new RecordObject(); + nsRecordContent.setContent(getHostNameWithTrailingDot(ns)); + nsRecordContent.setDisabled(false); + return nsRecordContent; + }) + .collect(Collectors.toList()))); + + // add the NS record to the TLD zone + zone.setRrsets(new ArrayList(Arrays.asList(newNsRecord))); + + // call the PowerDNS API to commit the changes + powerDnsClient.patchZone(zone); + logger.atInfo().log("Successfully updated NS records for PowerDNS TLD zone %s", zone.getName()); + } + + /** + * 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 { + // 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", getHostNameWithoutTrailingDot(zone.getName()), TSIG_KEY_NAME); + + // 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 + * 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 + 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)); + + // 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, expected root 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, expected root 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 + 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()); + + // identify the inactive ZSK + 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 host name with a trailing dot. + * + * @param hostName the fully qualified hostname + * @return the host name with a trailing dot + */ + private String getHostNameWithTrailingDot(String hostName) { + String normalizedHostName = hostName.toLowerCase(Locale.US).trim(); + return normalizedHostName.endsWith(".") ? normalizedHostName : normalizedHostName + '.'; + } + + /** + * Returns the host name without the trailing dot. + * + * @param hostName the fully qualified hostname + * @return the sanitized host name + */ + private String getHostNameWithoutTrailingDot(String hostName) { + // return the host name without the trailing dot + String normalizedHostName = hostName.toLowerCase(Locale.US).trim(); + return normalizedHostName.endsWith(".") + ? normalizedHostName.substring(0, normalizedHostName.length() - 1) + : normalizedHostName; + } + + /** + * Prepare the TLD zone for updates by clearing the RRSets and incrementing the serial number. + * + * @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) throws IOException { + Zone tldZone = new Zone(); + tldZone.setId(getTldZoneId()); + tldZone.setName(getHostNameWithoutTrailingDot(tldZoneName)); + tldZone.setRrsets(records); + return tldZone; + } + + /** + * 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 getAndValidateTldZoneByName() throws IOException { + // retrieve an existing TLD zone by name + for (Zone zone : powerDnsClient.listZones()) { + if (getHostNameWithoutTrailingDot(zone.getName()) + .equals(getHostNameWithoutTrailingDot(tldZoneName))) { + // retrieve full zone details + Zone fullZone = powerDnsClient.getZone(zone.getId()); + + // validate the zone's configuration + validateZoneConfig(fullZone); + return fullZone; + } + } + + // attempt to create a new TLD zone if it does not exist + try { + // create a new TLD zone + Zone zone = createZone(); + + // validate the zone's configuration + validateZoneConfig(zone); + return zone; + } catch (Exception e) { + // log the error and continue + logger.atWarning().log("Failed to create PowerDNS TLD zone %s: %s", tldZoneName, e); + } + + // otherwise, throw an exception + 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. 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 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; + } + + /** + * 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(""); + } +} 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..ffa99b2b6ef --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/PowerDnsWriterModule.java @@ -0,0 +1,41 @@ +// 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; +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..56f4ce1f476 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/PowerDNSClient.java @@ -0,0 +1,571 @@ +// 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; +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.TSIGKey; +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; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.Buffer; + +/** + * 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 { + // class variables + private final OkHttpClient httpClient; + private final ObjectMapper objectMapper; + private final String baseUrl; + private final String apiKey; + + // 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 + // 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.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(300, TimeUnit.SECONDS) + .build(); + this.objectMapper = new ObjectMapper(); + + // 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"); + } + } + } + + 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, 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 + 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); + + // 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; + } + + /** ZONE AND SERVER MANAGEMENT */ + public List listServers() throws IOException { + Request request = + new Request.Builder().url(baseUrl + "/servers").header("X-API-Key", apiKey).get().build(); + + try (Response response = logAndExecuteRequest(request)) { + 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 = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to get server: " + response); + } + return objectMapper.readValue(Objects.requireNonNull(response.body()).string(), Server.class); + } + } + + 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 = logAndExecuteRequest(request)) { + 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 = logAndExecuteRequest(request)) { + 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 = logAndExecuteRequest(request)) { + 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 = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to delete zone: " + response); + } + } + } + + 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/" + zone.getId()) + .header("X-API-Key", apiKey) + .patch(body) + .build(); + + try (Response response = logAndExecuteRequest(request)) { + if (!response.isSuccessful()) { + throw new IOException("Failed to patch zone: " + response); + } + } + } + + 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() + .url(baseUrl + "/servers/" + serverId + "/zones/" + zoneId + "/notify") + .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); + } + } + } + + 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); + } + } + } + + /** 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/Comment.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java new file mode 100644 index 00000000000..b10af9ab08b --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Comment.java @@ -0,0 +1,54 @@ +// 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 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/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); + } +} 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..97d46412cfe --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RRSet.java @@ -0,0 +1,93 @@ +// 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 RRSet { + @JsonProperty("name") + private String name; + + @JsonProperty("type") + private String type; + + @JsonProperty("ttl") + private long 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 long getTtl() { + return ttl; + } + + public void setTtl(long 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..7f3ced1ecf7 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/RecordObject.java @@ -0,0 +1,43 @@ +// 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 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..518be6aa120 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Server.java @@ -0,0 +1,98 @@ +// 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 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/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/client/model/Zone.java b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java new file mode 100644 index 00000000000..688981beb3b --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/client/model/Zone.java @@ -0,0 +1,294 @@ +// 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 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; + } + + @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,total:%d}", + id, name, deletedCount, updatedCount, rrsets.size()); + } + + public enum ZoneKind { + Native, + Master, + Slave, + Producer, + Consumer + } +} 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..4c805f7a9f1 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/resources/management.sh @@ -0,0 +1,137 @@ +# 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. + +# 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 + +## 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 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..b820599f7ba --- /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"' 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..1980c0b34d2 --- /dev/null +++ b/core/src/main/java/google/registry/dns/writer/powerdns/resources/pdns.conf @@ -0,0 +1,50 @@ +# 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 + +################################ +# 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 +# +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 diff --git a/core/src/main/java/google/registry/module/RegistryComponent.java b/core/src/main/java/google/registry/module/RegistryComponent.java index afd01c12b2f..6d53d31804e 100644 --- a/core/src/main/java/google/registry/module/RegistryComponent.java +++ b/core/src/main/java/google/registry/module/RegistryComponent.java @@ -26,6 +26,7 @@ import google.registry.config.RegistryConfig.Config; import google.registry.config.RegistryConfig.ConfigModule; import google.registry.dns.writer.VoidDnsWriterModule; +import google.registry.dns.writer.powerdns.PowerDnsConfig.PowerDnsConfigModule; import google.registry.export.DriveModule; import google.registry.export.sheet.SheetsServiceModule; import google.registry.flows.ServerTridProviderModule; @@ -62,6 +63,7 @@ BigqueryModule.class, CloudTasksUtilsModule.class, ConfigModule.class, + PowerDnsConfigModule.class, ConsoleConfigModule.class, CredentialModule.class, CustomLogicFactoryModule.class, diff --git a/core/src/main/java/google/registry/module/backend/BackendComponent.java b/core/src/main/java/google/registry/module/backend/BackendComponent.java index 5c8b96a70f2..a18a35818c1 100644 --- a/core/src/main/java/google/registry/module/backend/BackendComponent.java +++ b/core/src/main/java/google/registry/module/backend/BackendComponent.java @@ -22,6 +22,7 @@ import google.registry.config.CloudTasksUtilsModule; import google.registry.config.CredentialModule; import google.registry.config.RegistryConfig.ConfigModule; +import google.registry.dns.writer.powerdns.PowerDnsConfig.PowerDnsConfigModule; import google.registry.export.DriveModule; import google.registry.export.sheet.SheetsServiceModule; import google.registry.flows.ServerTridProviderModule; @@ -53,6 +54,7 @@ BatchModule.class, BigqueryModule.class, ConfigModule.class, + PowerDnsConfigModule.class, CloudTasksUtilsModule.class, CredentialModule.class, CustomLogicFactoryModule.class, diff --git a/core/src/test/java/google/registry/beam/billing/InvoicingPipelineTest.java b/core/src/test/java/google/registry/beam/billing/InvoicingPipelineTest.java index 6cad4a92842..9d5a5097578 100644 --- a/core/src/test/java/google/registry/beam/billing/InvoicingPipelineTest.java +++ b/core/src/test/java/google/registry/beam/billing/InvoicingPipelineTest.java @@ -391,7 +391,7 @@ void testSuccess_makeCloudSqlQuery() throws Exception { // Test that comments are removed from the .sql file correctly assertThat(InvoicingPipeline.makeCloudSqlQuery("2017-10")) .isEqualTo( - """ +""" SELECT b, r FROM BillingEvent b JOIN Registrar r ON b.clientId = r.registrarId diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleRegistryLockActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleRegistryLockActionTest.java index 4ff52cc2899..e1ea4385e66 100644 --- a/core/src/test/java/google/registry/ui/server/console/ConsoleRegistryLockActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleRegistryLockActionTest.java @@ -75,7 +75,8 @@ public class ConsoleRegistryLockActionTest extends ConsoleActionBaseTestCase { Note: this code will expire in one hour. https://registrarconsole.tld/console/#/registry-lock-verify?lockVerificationCode=\ - 123456789ABCDEFGHJKLMNPQRSTUVWXY"""; + 123456789ABCDEFGHJKLMNPQRSTUVWXY\ + """; @Mock GmailClient gmailClient; private ConsoleRegistryLockAction action; @@ -112,7 +113,7 @@ void testGet_simpleLock() { assertThat(response.getStatus()).isEqualTo(SC_OK); assertThat(response.getPayload()) .isEqualTo( - """ +""" [{"domainName":"example.test","registrarPocId":"johndoe@theregistrar.com","lockRequestTime":\ {"creationTime":"2024-04-15T00:00:00.000Z"},"unlockRequestTime":"null","lockCompletionTime":\ "2024-04-15T00:00:00.000Z","unlockCompletionTime":"null","isSuperuser":false}]\ @@ -206,7 +207,7 @@ void testGet_allCurrentlyValidLocks() { // locks or completed unlocks assertThat(response.getPayload()) .isEqualTo( - """ +""" [{"domainName":"adminexample.test","lockRequestTime":{"creationTime":"2024-04-16T00:00:00.001Z"},\ "unlockRequestTime":"null","lockCompletionTime":"2024-04-16T00:00:00.001Z","unlockCompletionTime":\ "null","isSuperuser":true},\ @@ -225,7 +226,8 @@ void testGet_allCurrentlyValidLocks() { \ {"domainName":"pending.test","registrarPocId":"johndoe@theregistrar.com","lockRequestTime":\ {"creationTime":"2024-04-16T00:00:00.001Z"},"unlockRequestTime":"null","lockCompletionTime":"null",\ -"unlockCompletionTime":"null","isSuperuser":false}]"""); +"unlockCompletionTime":"null","isSuperuser":false}]\ +"""); } @Test diff --git a/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java b/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java index d2b5bbac84e..67f470e7cda 100644 --- a/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java @@ -101,9 +101,10 @@ void testSuccess_delete() { assertThat(response.getStatus()).isEqualTo(SC_OK); assertThat(response.getPayload()) .isEqualTo( - """ +""" {"example.tld":{"message":"Command completed successfully; action pending",\ -"responseCode":1001}}"""); +"responseCode":1001}}\ +"""); assertThat(loadByEntity(domain).getDeletionTime()).isEqualTo(clock.nowUtc().plusDays(35)); ConsoleUpdateHistory history = loadSingleton(ConsoleUpdateHistory.class).get(); assertThat(history.getType()).isEqualTo(ConsoleUpdateHistory.Type.DOMAIN_DELETE); @@ -122,7 +123,8 @@ void testSuccess_suspend() throws Exception { assertThat(response.getPayload()) .isEqualTo( """ - {"example.tld":{"message":"Command completed successfully","responseCode":1000}}"""); + {"example.tld":{"message":"Command completed successfully","responseCode":1000}}\ + """); assertThat(loadByEntity(domain).getStatusValues()) .containsAtLeastElementsIn(serverSuspensionStatuses); ConsoleUpdateHistory history = loadSingleton(ConsoleUpdateHistory.class).get(); @@ -145,7 +147,8 @@ void testSuccess_unsuspend() throws Exception { assertThat(response.getPayload()) .isEqualTo( """ - {"example.tld":{"message":"Command completed successfully","responseCode":1000}}"""); + {"example.tld":{"message":"Command completed successfully","responseCode":1000}}\ + """); assertThat(loadByEntity(domain).getStatusValues()).containsNoneIn(serverSuspensionStatuses); ConsoleUpdateHistory history = loadSingleton(ConsoleUpdateHistory.class).get(); assertThat(history.getType()).isEqualTo(ConsoleUpdateHistory.Type.DOMAIN_UNSUSPEND); @@ -167,10 +170,11 @@ void testHalfSuccess_halfNonexistent() throws Exception { assertThat(response.getStatus()).isEqualTo(SC_OK); assertThat(response.getPayload()) .isEqualTo( - """ +""" {"example.tld":{"message":"Command completed successfully; action pending","responseCode":1001},\ "nonexistent.tld":{"message":"The domain with given ID (nonexistent.tld) doesn\\u0027t exist.",\ -"responseCode":2303}}"""); +"responseCode":2303}}\ +"""); assertThat(loadByEntity(domain).getDeletionTime()).isEqualTo(clock.nowUtc().plusDays(35)); ConsoleUpdateHistory history = loadSingleton(ConsoleUpdateHistory.class).get(); assertThat(history.getType()).isEqualTo(ConsoleUpdateHistory.Type.DOMAIN_DELETE);