The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/../lib";
use Net::Amazon::Route53;
use Getopt::Long;
use Pod::Usage;

# Show help page or man
my $man  = 0;
my $help = 0;

# Either keyfile + friendly name
my $keyfile = $ENV{HOME} . '/.aws-secrets';
my $keyname = '';

# Or key and id
my $key = '';
my $id  = '';

# Other options, command-specific
my %options = (
    comment         => '',
    callerreference => '',
    wait            => 0,
    type            => '',
    name            => '',
    ttl             => '',
);
my (@_value);

GetOptions(
    'help|?'    => \$help,
    'man'       => \$man,
    'keyfile=s' => \$keyfile,
    'keyname=s' => \$keyname,
    'key=s'     => \$key,
    'id=s'      => \$id,

    'comment=s'         => \$options{comment},
    'callerreference=s' => \$options{callerreference},
    'wait'              => \$options{wait},
    'type=s'            => \$options{type},
    'name=s'            => \$options{name},
    'ttl=s'             => \$options{ttl},
    'value=s'           => \@_value,

) or pod2usage(1);
$options{value} = \@_value;
pod2usage(1) if $help;
pod2usage(-exitstatus => 0, -verbose => 2) if $man;

pod2usage("Either provide a keyname/keyfile, or a key/id\n")
    if (!length $keyname and (!length $key or !length $id));
if (length $keyname) {
    pod2usage("Need a keyfile when a keyname is given\n") if (!length $keyfile);
    die("No such keyfile: $keyfile\n") if (!-f $keyfile);
    my $keyfile_contents = do {
        local $/;
        open my $f, '<', $keyfile or die "Cannot open $keyfile for reading: $!";
        my $tmp = <$f>;
        close $f or die "Cannot close $keyfile: $!";
        $tmp;
    };
    my %awsSecretAccessKeys;
    eval "$keyfile_contents" or die "Cannot parse $keyfile\'s contents.\n";
    die("No such friendly key $keyname in $keyfile\n")
        if !exists $awsSecretAccessKeys{$keyname};
    die("Friendly key $keyname in $keyfile misses 'id'\n")
        if !exists $awsSecretAccessKeys{$keyname}{id};
    die("Friendly key $keyname in $keyfile misses 'key'\n")
        if !exists $awsSecretAccessKeys{$keyname}{key};
    $id      = $awsSecretAccessKeys{$keyname}{id};
    $key     = $awsSecretAccessKeys{$keyname}{key};
    $keyfile = $keyname = '';
}
if (length $key or length $id) {
    pod2usage("Both id and key must be given\n")
        if (!length $key or !length $id);
}

pod2usage("Need an action") if (!@ARGV);

my %actions = (
    'list'        => \&do_list,
    'nameservers' => \&do_nameservers,
    'zone'        => \&do_zone,
    'record'      => \&do_record,
);
my $action = shift @ARGV;
pod2usage("Need an action\nUse route53 --help to list the allowed actions")
    unless defined $action;
pod2usage(
    "Unrecognised action: $action\nUse route53 --help to list the allowed actions"
) unless exists $actions{$action};
pod2usage("Resource Record name need to end in a dot")
    if $options{name} and $options{name} !~ /\.$/;
pod2usage("Resource Record TTL need to be numeric")
    if $options{ttl} and $options{ttl} !~ /^\d+$/;

my $route53 = Net::Amazon::Route53->new(id => $id, key => $key);

my $outcome = $actions{$action}->($route53, @ARGV);
print $outcome;

sub do_list {
    my $route53      = shift;
    my @hosted_zones = $route53->get_hosted_zones();
    return "No hosted zones associated with this account\n" if !@hosted_zones;
    my $output = '';
    for my $hosted_zone (@hosted_zones) {
        $output .= "Hosted zone:\n";
        $output .= "  id: " . $hosted_zone->id . "\n";
        $output .= "  name: " . $hosted_zone->name . "\n";
        $output .= "  callerreference: " . $hosted_zone->callerreference . "\n";
        $output .= "  comment: " . $hosted_zone->comment . "\n";
    }
    return $output;
}

sub do_nameservers {
    my $route53      = shift;
    my $which        = shift;
    my @hosted_zones = $route53->get_hosted_zones($which);
    return "No hosted zones associated with this account\n"
        if (!$which and !@hosted_zones);
    return "No such hosted zone $which\n" if ($which and !@hosted_zones);
    my $output = '';
    for my $hosted_zone (@hosted_zones) {
        if (!$which) {
            $output .= "Hosted zone:\n";
            $output .= "  id: " . $hosted_zone->id . "\n";
            $output .= "  name: " . $hosted_zone->name . "\n";
            $output .=
                "  callerreference: " . $hosted_zone->callerreference . "\n";
            $output .= "  comment: " . $hosted_zone->comment . "\n";
        }
        my @nameservers = @{ $hosted_zone->nameservers };
        for my $nameserver (@nameservers) {
            $output .= ($which ? '' : '  nameserver: ') . "$nameserver\n";
        }
    }
    return $output;
}

sub do_zone {
    my $route53 = shift;
    my $which   = shift;
    die "Need a zone or action name\n" if !$which;

    # actions
    if ($which eq 'create') {
        my $zone = shift;
        die "Need a zone name to create\n" if !$zone;
        die "Zone needs to end in a dot\n" if $zone !~ /\.$/;
        $options{callerreference} = sprintf("%s-%s-%s", $zone, time, $$)
            if !length $options{callerreference};
        print "Creating new zone '$zone'\n",
            "  comment:          '$options{comment}'\n",
            "  caller reference: '$options{callerreference}'\n";
        my $new_zone = Net::Amazon::Route53::HostedZone->new(
            route53         => $route53,
            name            => $zone,
            comment         => $options{comment},
            callerreference => $options{callerreference},
        );
        eval {$new_zone->create(wait => $options{wait}, @_)}
            or die "Could not create zone $zone: $@\n";
        return "Zone created\n";
    }
    if ($which eq 'delete') {
        my $zone = shift;
        die "Need a zone name to delete\n" if !$zone;
        die "Zone needs to end in a dot\n" if $zone !~ /\.$/;
        my @hosted_zones = $route53->get_hosted_zones($zone);
        return "No such hosted zone $zone\n" if (!@hosted_zones);
        eval {$hosted_zones[0]->delete(wait => $options{wait}, @_)}
            or die "Could not delete zone $zone: $@\n";
        return "Zone deleted\n";
    }

    # Not an action; bail out if it doesn't look like a zone
    die "Unrecognised action/zone $which\n" if ($which !~ /\.$/);

    do_nameservers($route53, $which);
}

sub do_record {
    my $route53 = shift;
    my $which   = shift;
    die "Need a zone or action name\n" if !$which;

    # actions
    if ($which eq 'list' or $which =~ /\.$/) {
        my $zone = ($which =~ /\.$/ ? $which : shift);
        die "Need a zone name to list\n"   if !$zone;
        die "Zone needs to end in a dot\n" if $zone !~ /\.$/;
        my @hosted_zones = $route53->get_hosted_zones($zone);
        return "No such hosted zone $zone\n" if (!@hosted_zones);
        my $output = '';
        for my $rrs (@{ $hosted_zones[0]->resource_record_sets() }) {
            next if $options{type} and uc($rrs->type) ne uc($options{type});
            next if $options{ttl}  and $rrs->ttl ne $options{ttl};
            if (@{ $options{value} } and @{ $options{value} } > 0) {
                next if !scalar grep {
                    my $v = $_;
                    scalar grep {$_ eq $v} @{ $options{value} }
                } @{ $rrs->values };
            }
            next if $options{name} and $rrs->name ne $options{name};
            $output .= sprintf("%s %s %s %s\n",
                $rrs->name, $rrs->type, $rrs->ttl, join(' ', @{ $rrs->values }),
            );
        }
        return $output;
    }
    if ($which eq 'delete') {
        my $zone = shift;
        die "Need a zone name to delete\n" if !$zone;
        die "Zone needs to end in a dot\n" if $zone !~ /\.$/;
        my @hosted_zones = $route53->get_hosted_zones($zone);
        return "No such hosted zone $zone\n" if (!@hosted_zones);

        my @records = @{ $hosted_zones[0]->resource_record_sets() };
        @records = grep {$_->name eq $options{name}} @records
            if length $options{name};

        # weed out by record type, ttl, and value
        @records = grep {$_->type eq $options{type}} @records if $options{type};
        @records = grep {$_->ttl eq $options{ttl}} @records
            if length $options{ttl};
        if (@{ $options{value} }) {
            @records = grep {
                my $r = $_;
                grep {
                    my $v = $_;
                    scalar grep {$_ eq $v} @{ $options{value} }
                    } @{ $r->values }
            } @records;
        }
        die "No record matches\n" if (!@records);
        die "Too many records match:\n", join(
            "\n",
            map {
                sprintf("%s %s %s %s",
                    $_->name, $_->type, $_->ttl, join(' ', @{ $_->values }))
            } @records
            ),
            "\n"
            if @records > 1;
        eval {$records[0]->delete(wait => $options{wait}, @_)}
            or die "Could not delete record: $@\n";
        return "Record deleted\n";
    }
    if ($which eq 'create') {
        my $zone = shift;
        die "Need a zone name to create a record for\n" if !$zone;
        die "Zone needs to end in a dot\n" if $zone !~ /\.$/;
        my @hosted_zones = $route53->get_hosted_zones($zone);
        return "No such hosted zone $zone\n" if (!@hosted_zones);
        my @errors;
        push @errors, "Need a --name for the new record\n"
            if !length $options{name};
        push @errors, "Need a --ttl for the new record\n"
            if !length $options{ttl};
        push @errors, "Need a --type for the new record\n"
            if !length $options{type};
        push @errors, "Need one or more --value for the new record\n"
            if !@{ $options{value} };
        push @errors, "Unrecognised --type for record: $options{type}\n"
            if (length $options{type}
            and $options{type} !~
            /^(A|AAAA|CNAME|MX|NS|PTR|SOA|SPF|SRV|TXT)$/i);
        die join('', @errors) if @errors;
        my $new_record = Net::Amazon::Route53::ResourceRecordSet->new(
            route53    => $route53,
            hostedzone => $hosted_zones[0],
            name       => $options{name},
            ttl        => $options{ttl},
            type       => uc $options{type},
            values     => $options{value},
        );
        eval {$new_record->create(wait => $options{wait}, @_)}
            or die "Could not create record: $@\n";
        return "Record created\n";
    }

    # Not an action
    die "Unrecognised action $which\n";
}

__END__

=head1 NAME

route53 - Manage your DNS entries on Amazon's Route53 service

=head1 DESCRIPTION

B<route53> will manage your Amazon Route 53 account

=head1 SYNOPSIS

route53 {key and id} [options] action [action arguments]

Either C<-keyfile> and C<-keyname> or C<-id> and C<-key> must be provided.

=head2 OPTIONS

=over 8

=item B<-keyfile>

The file which contains the keys and ids for the Route53 service,
in the format used by Amazon's "route53.pl" script:

    %awsSecretAccessKeys = (
        "my-aws-account" => {
            id => "ABCDEFG",
            key => "12345",
        },
    );

Defaults to C<~/.aws-secrets> when not given.

=item B<-keyname>

The name of the key to be used; in the above C<-keyfile> example,
it could be C<my-aws-account>.

=item B<-id>

The AWS id to be used; in the above example it could be
C<ABCDEFG>.

=item B<-key>

The AWS key to be used; in the above example it could be
C<12345>.

=item B<-wait>

For the commands which support it, waits for the change requested to be in
C<INSYNC> status before returning.  This is done by querying for the change
status every 2 seconds until the change is C<INSYNC>. Defaults to 0, meaning
the requests return immediately.

=item B<-help>

Prints the help page and exits

=item B<-man>

Prints the manual page and exits

=back

=head1 ARGUMENTS

B<route53> performs a number of B<actions>, each of which may take
a number of arguments:

=over 8

=item B<list>

Lists the hosted zones currently associated with the account.
Takes no arguments.

=item B<nameservers>

Lists the nameservers for all the hosted zones currently associated with the
account. Takes a hosted zone name as an optional argument to just show the
nameservers associated with that zone.

=item B<zone>

Performs actions on a specific DNS zone. If a zone name is given, rather than
an action, it shows the nameservers associated with the zone.

Possible actions are:

=over 8

=item B<create>

Needs C<--comment> and optional C<--callerreference>. Creates a new zone.
Supports the C<--wait> option.

=item B<delete>

Deletes the zone. The zone needs to be empty (containing only NS and SOA
entries) before Amazon's Route53 allows its deletion. Supports the C<--wait>
option.

=back

=item B<record>

Performs actions on a specific DNS zone record. A DNS zone name must be given.
If no action is provided, it lists all records for the zone.

Possible actions are:

=over 8

=item B<list>

This is the default action if no action is specified. Lists all DNS records for
the zone.

If a C<--type> is given, it lists only the records of the given type.
If a C<--name> is given, it lists only the records which have the given name.
If a C<--ttl> is given, it lists only the records which have the given TTL.
If a C<--value> is given, it lists only the records which have a value matching the given one.

Wildcard records (i.e. C<*.example.com>) are displayed as C<\052.example.com>.
The same format must be used to create a wildcard record.

=item B<delete>

Deletes one DNS record for the zone given. Can only delete a record which
is univocally identified by filtering the records list by C<--name>, C<--type>,
C<--ttl> and C<--value>. Dies listing the matching records if too many entries
match. Supports the C<--wait> option.

=item B<create>

Creates a DNS record for the zone given. Needs all the following options
in order to create the record: C<--name>, C<--type>, C<--ttl> and one or
more C<--value>. Supports the C<--wait> option.

=back

=back

=head1 EXAMPLES

=head2 Specify your credentials

You need to specify your credentials with one of the following notations.
All the examples below use the C<--keyname> notation, defaulting to using
the C<~/.aws-secrets> file.

    # Uses ~/.aws-secrets as repository, key name is specified
    $ route53 --keyname my-aws-keyname

    # Uses the given key file and key name
    $ route53 --keyfile ~/.aws --keyname my-aws-keyname

    # Uses the given key and id
    $ route53 --key ABCDE --id DEFG

=head2 List your zones

Lists the zones names, ids and comments:

    $ route53 --keyname my-aws-account list
    Hosted zone:
      id: /hostedzone/ABCDEFG
      name: example.com.
      callerreference: FGHIJK
      comment: Zone for example.com.
    Hosted zone:
      id: /hostedzone/FGHJKL
      name: anotherexample.com.
      callerreference: QWERTY
      comment: Zone for anotherexample.com.

=head2 Get all nameservers (and details) for all zones

Displays a verbose list of the zone details and the nameservers
which are authoritative for the zone:

    $ route53 --keyname my-aws-account nameservers
    Hosted zone:
      id: /hostedzone/ABCDEFG
      name: example.com.
      callerreference: FGHIJK
      comment: Zone for example.com.
      nameserver: ns-123.awsdns-123.com
      nameserver: ns-123.awsdns-123.co.uk
      nameserver: ns-123.awsdns-123.org
    Hosted zone:
      id: /hostedzone/FGHJKL
      name: anotherexample.com.
      callerreference: QWERTY
      comment: Zone for anotherexample.com.
      nameserver: ns-456.awsdns-456.com
      nameserver: ns-456.awsdns-456.co.uk
      nameserver: ns-456.awsdns-456.org

=head2 Get just the nameservers for a specific zone

Displays a terse list of the nameservers, one per line:

    $ route53 --keyname my-aws-account nameservers example.com.
    ns-123.awsdns-123.com
    ns-123.awsdns-123.co.uk
    ns-123.awsdns-123.org

This allows the nameservers to be used in scripting:

    $ for nameserver in
        $( route53 --keyname my-aws-account nameservers example.com. );
      do
        # do whatever you want with $nameserver
      done;

=head2 Create a new zone

Creates a new zone:

    $ route53 --keyname my-aws-account zone create example.com. \
        --comment 'Zone for example.com.'
        --callerreference 'unique id for this'

You can optionally specify C<--wait> to wait for the zone to have been
effectively created. Otherwise the command returns as soon as the request
has been sent to Route 53.

=head2 Delete a zone

Deletes a zone (assuming the zone contains only C<SOA> and C<NS> records):

    $ route53 --keyname my-aws-account zone delete example.com.

You can optionally specify C<--wait> to wait for the zone to have been
effectively deleted. Otherwise the command returns as soon as the request
has been sent to Route 53.

=head2 List all DNS records for a zone

Lists all DNS records for a zone:

    $ route53 --keyname my-aws-account record list example.com.
    example.com. A 14400 127.0.0.1
    example.com. MX 14400 127.0.0.1
    example.com. NS 172800 ns-123.awsdns-123.com. ns-123.awsdns-123.co.uk. ns-123.awsdns-123.org.
    example.com. SOA 900 ns-123.awsdns-123.com. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400
    \052.example.com. A 300 127.0.0.1

You can optionally specify C<--type> to display only DNS records of a given type:

    $ route53 --keyname my-aws-account record list example.com. --type A
    example.com. A 14400 127.0.0.1
    \052.example.com. A 300 127.0.0.1

=head2 Delete a specific DNS record for a zone

This example assumes we want to remove the C<\052.example.com.> entry.  One can
check which parameters are needed to get the correct entry with the
C<record list> first:

    $ route53 --keyname my-aws-account record list example.com. --type A
    example.com. A 14400 127.0.0.1
    \052.example.com. A 300 127.0.0.1
    $ route53 --keyname my-aws-account record list example.com. --type A --ttl 300
    \052.example.com. A 300 127.0.0.1

Or can read the error message given in case there are too many matching records:

    $ route53 --keyname my-aws-account record delete example.com. --type A
    Too many records match:
    example.com. A 14400 127.0.0.1
    \052.example.com. A 300 127.0.0.1

The lone record deletion:

    $ route53 --keyname my-aws-account record delete example.com. --type A --ttl 300

=head2 Create a new DNS record for the zone

This adds a new record for the zone:

    $ route53 --keyname my-aws-account record create example.com. \
        --name test.example.com. --type A --ttl 300 \
        --value 127.0.0.1

=cut

=head1 AUTHOR

Marco FONTANI <mfontani@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2011 by Marco FONTANI.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut