[java-idp-integration-tests] 01/04: Add helper for Route 53 DNS records
Tom Zeller
tzeller at dragonacea.biz
Wed Jan 31 22:31:59 UTC 2024
This is an automated email from the git hooks/post-receive script.
tzeller pushed a commit to branch main
in repository java-idp-integration-tests.
View the commit online:
http://git.shibboleth.net/view/?p=java-idp-integration-tests.git;a=commit;h=837878f12c24da15d3eb8384e330b8473b1e0007
commit 837878f12c24da15d3eb8384e330b8473b1e0007
Author: Tom Zeller <tzeller at dragonacea.biz>
AuthorDate: Wed Jan 31 16:01:22 2024 -0600
Add helper for Route 53 DNS records
https://shibboleth.atlassian.net/browse/IDP-2228
---
pom.xml | 13 +
.../idp/integration/tests/Route53Helper.java | 527 +++++++++++++++++++++
2 files changed, 540 insertions(+)
diff --git a/pom.xml b/pom.xml
index 2bdabf6..92fc895 100644
--- a/pom.xml
+++ b/pom.xml
@@ -51,6 +51,8 @@
<!-- Redirect test output to file target/surefire-reports/testName-output.txt -->
<redirectTestOutputToFile>true</redirectTestOutputToFile>
+
+ <aws.java.sdk.version>2.23.1</aws.java.sdk.version>
</properties>
<dependencyManagement>
@@ -69,6 +71,13 @@
<type>pom</type>
<scope>import</scope>
</dependency>
+ <dependency>
+ <groupId>software.amazon.awssdk</groupId>
+ <artifactId>bom</artifactId>
+ <version>${aws.java.sdk.version}</version>
+ <type>pom</type>
+ <scope>import</scope>
+ </dependency>
</dependencies>
</dependencyManagement>
@@ -114,6 +123,10 @@
<version>3.5</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>software.amazon.awssdk</groupId>
+ <artifactId>route53</artifactId>
+ </dependency>
</dependencies>
diff --git a/src/test/java/net/shibboleth/idp/integration/tests/Route53Helper.java b/src/test/java/net/shibboleth/idp/integration/tests/Route53Helper.java
new file mode 100644
index 0000000..3e91441
--- /dev/null
+++ b/src/test/java/net/shibboleth/idp/integration/tests/Route53Helper.java
@@ -0,0 +1,527 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements. See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID licenses this file to You under the Apache
+ * License, Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.integration.tests;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.beust.jcommander.JCommander;
+import com.beust.jcommander.MissingCommandException;
+import com.beust.jcommander.Parameter;
+import com.beust.jcommander.Parameters;
+import com.google.common.net.InetAddresses;
+
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.route53.Route53Client;
+import software.amazon.awssdk.services.route53.model.Change;
+import software.amazon.awssdk.services.route53.model.ChangeAction;
+import software.amazon.awssdk.services.route53.model.ChangeBatch;
+import software.amazon.awssdk.services.route53.model.ChangeResourceRecordSetsRequest;
+import software.amazon.awssdk.services.route53.model.ChangeResourceRecordSetsResponse;
+import software.amazon.awssdk.services.route53.model.ChangeStatus;
+import software.amazon.awssdk.services.route53.model.GetChangeRequest;
+import software.amazon.awssdk.services.route53.model.GetChangeResponse;
+import software.amazon.awssdk.services.route53.model.HostedZone;
+import software.amazon.awssdk.services.route53.model.ListHostedZonesRequest;
+import software.amazon.awssdk.services.route53.model.ListHostedZonesResponse;
+import software.amazon.awssdk.services.route53.model.RRType;
+import software.amazon.awssdk.services.route53.model.ResourceRecord;
+import software.amazon.awssdk.services.route53.model.ResourceRecordSet;
+
+/**
+ * Help create and delete DNS A records in Route 53.
+ *
+ * Default is to determine the public IP address and create / delete A records
+ * where the host name is the public IP address with dashes instead of dots.
+ *
+ * For example :
+ *
+ * 192-168-1-1.tests.shibboleth.net. 10 A 192.168.1.1
+ *
+ * Credentials are determined using the {@link DefaultCredentialsProvider}.
+ *
+ */
+public class Route53Helper extends AbstractInitializableComponent implements AutoCloseable {
+
+ @Parameters(commandNames = { "create" }, commandDescription = "Create DNS A record.")
+ public class CreateCommand {
+ }
+
+ @Parameters(commandNames = { "delete" }, commandDescription = "Delete DNS A record.")
+ public class DeleteCommand {
+ }
+
+ @Parameter(names = "--help", help = true, order = 0)
+ private boolean help;
+
+ @Parameter(names = "--address", description = "Host address for DNS A record. Defaults to public IP address.", order = 1)
+ protected String address;
+
+ @Parameter(names = "--name", description = "Host name for DNS A record. Defaults to address with dashes instead of dots. The DNS domain name is appended to form a FQDN.", order = 2)
+ protected String name;
+
+ @Parameter(names = "--domain", description = "DNS domain name.")
+ protected String domain = "tests.shibboleth.net.";
+
+ @Parameter(names = "--region", description = "AWS Region.")
+ protected String regionParameter = "us-east-1";
+
+ @Parameter(names = "--ttl", description = "DNS record TTL.")
+ protected Long ttl = 30L;
+
+ @Parameter(names = "--wait", description = "Number of seconds to wait for AWS to be in sync.")
+ protected Integer wait = 60;
+
+ /** Command - either create or delete */
+ protected String command;
+
+ /** AWS hosted zone id */
+ @NonnullAfterInit
+ protected String hostedZoneId;
+
+ /** AWS Region */
+ @NonnullAfterInit
+ protected Region region;
+
+ /** AWS Route 53 client */
+ @NonnullAfterInit
+ protected Route53Client route53Client;
+
+ /** Class logger. */
+ @Nonnull
+ protected final Logger log = LoggerFactory.getLogger(Route53Helper.class);
+
+ /**
+ * Validate command.
+ *
+ * Validate AWS Region.
+ *
+ * Build Route 53 client using credentials from system properties, environment,
+ * or instance profile, the {@link DefaultCredentialsProvider}.
+ *
+ * Get AWS hosted zone id.
+ *
+ * Get public IP address if not supplied from command line.
+ *
+ * Get host name if not supplied from command line.
+ *
+ * {@inheritDoc}
+ */
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ // Region must be valid
+ region = Region.of(regionParameter);
+ if (!Region.regions().contains(region)) {
+ throw new ComponentInitializationException("Unknown region '" + regionParameter + "'");
+ }
+
+ // Get AWS credentials from system properties, environment variables, instance profile, etc.
+ final DefaultCredentialsProvider credentials = DefaultCredentialsProvider.create();
+
+ // Build Route 53 client
+ route53Client = Route53Client.builder() //
+ .region(region) //
+ .credentialsProvider(credentials) //
+ .build();
+
+ // Hosted zone id must be determined
+ hostedZoneId = getAWSHostedZoneId();
+ Constraint.isNotEmpty(hostedZoneId, "Unable to determine hosted zone id");
+
+ // If address is not supplied, get the public IP address
+ if (address == null) {
+ address = getPublicIPAddress();
+ log.debug("Public IP address '{}', address");
+ }
+
+ // If name is not supplied, calculate from the public IP address
+ if (name == null) {
+ name = getFQDN(address);
+ }
+ }
+
+ /**
+ * Run command.
+ *
+ * @param args
+ * command line arguments
+ */
+ public static void main(@Nonnull final String[] args) {
+
+ // Use try-with-resources to automatically close the underlying Route 53 client
+ try (final Route53Helper route53helper = new Route53Helper()) {
+
+ // Parse command line
+ route53helper.parseCommandLine(args);
+
+ // Initialize the Route 53 client
+ route53helper.initialize();
+
+ // Run the command
+ switch (route53helper.command) {
+ case "create" -> {
+ final boolean createdOk = route53helper.create();
+ if (!createdOk) {
+ System.exit(1); // Not yet in sync
+ }
+ System.out.printf("Created %s -> %s%n", route53helper.name, route53helper.address);
+ System.exit(0); // Success
+ }
+ case "delete" -> {
+ final boolean deletedOk = route53helper.delete();
+ if (!deletedOk) {
+ System.exit(1); // Not yet in sync
+ }
+ System.out.printf("Deleted %s -> %s%n", route53helper.name, route53helper.address);
+ System.exit(0); // Success
+ }
+ }
+
+ } catch (final ComponentInitializationException e) {
+ e.printStackTrace();
+ System.exit(1);
+ }
+ }
+
+ /**
+ * Parse command line.
+ *
+ * @param args
+ * command line arguments
+ */
+ public void parseCommandLine(@Nonnull final String[] args) {
+
+ final CreateCommand createCommand = this.new CreateCommand();
+ final DeleteCommand deleteCommand = this.new DeleteCommand();
+
+ // Build command line reader with commands
+ final JCommander jc = JCommander.newBuilder() //
+ .addObject(this) //
+ .addCommand(createCommand) //
+ .addCommand(deleteCommand) //
+ .build();
+
+ // Set program name for usage display
+ jc.setProgramName("Route 53 Helper");
+
+ // Parse command line, print usage for unknown command and exit
+ try {
+ jc.parse(args);
+ } catch (final MissingCommandException e) {
+ jc.usage();
+ System.exit(1);
+ }
+
+ // Print usage for help option and exit
+ if (help) {
+ jc.usage();
+ System.exit(0);
+ }
+
+ // Get command to be executed
+ command = jc.getParsedCommand();
+
+ // If command is not provided, print usage and exit
+ if (command == null) {
+ jc.usage();
+ System.exit(1);
+ }
+ }
+
+ /**
+ * Create DNS A record and wait for change to be in sync.
+ *
+ * @return true if record was created successfully, false otherwise
+ */
+ public boolean create() {
+
+ final ChangeResourceRecordSetsResponse response = createRecord(name, address);
+
+ return waitForSync(response);
+ }
+
+ /**
+ * Delete DNS A record and wait for change to be in sync.
+ *
+ * @return true if record was deleted successfully, false otherwise
+ */
+ public boolean delete() {
+
+ final ChangeResourceRecordSetsResponse response = deleteRecord(name, address);
+
+ return waitForSync(response);
+ }
+
+ /**
+ *
+ * Build DNS record with given name and value.
+ *
+ * DNS record is type A with TTL {@link Route53Helper#ttl}.
+ *
+ * @param name
+ * DNS name
+ * @param value
+ * DNS record value
+ * @return DNS record with given name and value
+ */
+ @Nonnull
+ public ResourceRecordSet buildRecord(@Nonnull final String name, @Nonnull final String value) {
+ return ResourceRecordSet.builder()
+ .name(name)
+ .resourceRecords(ResourceRecord.builder().value(value).build())
+ .ttl(ttl)
+ .type(RRType.A)
+ .build();
+ }
+
+ /**
+ * Create record.
+ *
+ * @param name
+ * DNS FQDN
+ * @param value
+ * DNS record value
+ * @return the response
+ */
+ @Nonnull
+ public ChangeResourceRecordSetsResponse createRecord(@Nonnull final String name, @Nonnull final String value) {
+
+ log.debug("Create record '{}' -> '{}'", name, value);
+
+ final ResourceRecordSet resourceRecordSet = buildRecord(name, value);
+
+ final Change change = Change.builder() //
+ .action(ChangeAction.UPSERT) //
+ .resourceRecordSet(resourceRecordSet) //
+ .build();
+
+ final ChangeBatch changeBatch = ChangeBatch.builder() //
+ .changes(change) //
+ .build();
+
+ final ChangeResourceRecordSetsRequest changeResourceRecordSets = ChangeResourceRecordSetsRequest.builder()
+ .hostedZoneId(hostedZoneId)
+ .changeBatch(changeBatch)
+ .build();
+
+ final ChangeResourceRecordSetsResponse changeResourceRecordSetsResponse = route53Client
+ .changeResourceRecordSets(changeResourceRecordSets);
+
+ log.debug("Created record '{}' -> '{}' change id '{}'", name, value,
+ changeResourceRecordSetsResponse.changeInfo().id());
+
+ return changeResourceRecordSetsResponse;
+ }
+
+ /**
+ * Delete record.
+ *
+ * @param name
+ * DNS FQDN
+ * @param value
+ * DNS record value
+ * @return the response
+ */
+ @Nonnull
+ public ChangeResourceRecordSetsResponse deleteRecord(@Nonnull final String name, @Nonnull final String value) {
+
+ log.debug("Delete record '{}' -> '{}'", name, value);
+
+ final ResourceRecordSet resourceRecordSet = buildRecord(name, value);
+
+ final Change change = Change.builder() //
+ .action(ChangeAction.DELETE) //
+ .resourceRecordSet(resourceRecordSet) //
+ .build();
+
+ final ChangeBatch changeBatch = ChangeBatch.builder() //
+ .changes(change) //
+ .build();
+
+ final ChangeResourceRecordSetsRequest changeResourceRecordSets = ChangeResourceRecordSetsRequest.builder()
+ .hostedZoneId(hostedZoneId)
+ .changeBatch(changeBatch)
+ .build();
+
+ final ChangeResourceRecordSetsResponse changeResourceRecordSetsResponse = route53Client
+ .changeResourceRecordSets(changeResourceRecordSets);
+
+ log.debug("Deleted record '{}' -> '{}' change id '{}'", name, value,
+ changeResourceRecordSetsResponse.changeInfo().id());
+
+ return changeResourceRecordSetsResponse;
+ }
+
+ /**
+ * Get fully qualified domain name from the given IP address.
+ *
+ * The hostname is the IP address with dots converted to dashes.
+ *
+ * The {@link #domain} is appended to the hostname.
+ *
+ * @param address
+ * IP address
+ * @return fully qualified domain name
+ */
+ @Nonnull
+ public String getFQDN(@Nonnull final String address) {
+
+ final String hostname = address.replace(".", "-");
+
+ final String fqdn = hostname + "." + domain;
+
+ return fqdn;
+ }
+
+ /**
+ * Get public IP address by sending request to http://checkip.amazonaws.com/
+ *
+ * TODO Option to use EC2 instance metadata
+ *
+ * @return public IP address
+ * @throws RuntimeException
+ * if IP address is not valid or an error occurs
+ */
+ public static String getPublicIPAddress() {
+ try {
+ final URL url = new URL("http://checkip.amazonaws.com/");
+
+ try (final BufferedReader br = new BufferedReader(new InputStreamReader(url.openStream()))) {
+
+ final String address = br.readLine();
+
+ if (!InetAddresses.isInetAddress(address)) {
+ throw new RuntimeException("Unable to determine public IP address");
+ }
+
+ return address;
+
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ } catch (MalformedURLException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Get hosted zone id for the {@link #domain} or null if not found.
+ *
+ * @return hosted zone id for the {@link #domain} or null
+ */
+ @Nullable
+ public String getAWSHostedZoneId() {
+
+ final ListHostedZonesRequest request = ListHostedZonesRequest.builder().build();
+
+ final ListHostedZonesResponse response = route53Client.listHostedZones(request);
+
+ for (final HostedZone hostedZone : response.hostedZones()) {
+ if (hostedZone.name().equalsIgnoreCase(domain)) {
+ return hostedZone.id();
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Whether change is in sync
+ *
+ * @param changeRequest
+ * the change request
+ * @return true if change is in sync, false otherwise
+ */
+ public boolean isInSync(@Nonnull final GetChangeRequest changeRequest) {
+
+ final GetChangeResponse changeResponse = route53Client.getChange(changeRequest);
+
+ return changeResponse.changeInfo().statusAsString().equalsIgnoreCase(ChangeStatus.INSYNC.toString());
+ }
+
+ /**
+ * Wait for change to be in sync or until {@link #timeout} is reached.
+ *
+ * @param response
+ * the AWS change response
+ * @return return true if AWS change is in sync or false if timeout reached
+ */
+ public boolean waitForSync(@Nonnull final ChangeResourceRecordSetsResponse response) {
+
+ // Get change id from response
+ final String responseId = response.changeInfo().id();
+
+ // Build change request from response change id.
+ final GetChangeRequest changeRequest = GetChangeRequest.builder().id(responseId).build();
+
+ // Count retry attempts
+ int count = 0;
+
+ // Whether change is in sync
+ boolean insync = isInSync(changeRequest);
+
+ log.debug("Waiting {} seconds for change '{}' to be in sync ...", wait, responseId);
+
+ // While not in sync
+ while (!insync) {
+
+ // Return false if timeout reached
+ if (count++ >= wait) {
+ log.debug("Waited {} seconds, change '{}' still not in sync", wait, responseId);
+ return false;
+ }
+
+ // Sleep 1 second
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ // Ignore sleep exception
+ }
+
+ // Whether change is in sync, again
+ insync = isInSync(changeRequest);
+ }
+
+ // Return true if change is in sync
+ log.debug("Change '{}' is in sync", responseId);
+ return true;
+ }
+
+ @Override
+ /** {@inheritDoc} */
+ public void close() {
+ if (route53Client != null) {
+ route53Client.close();
+ }
+ }
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list