[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