File Coverage

blib/lib/Net/DNS/CloudFlare/DDNS.pm
Criterion Covered Total %
statement 13 15 86.6
branch n/a
condition n/a
subroutine 5 5 100.0
pod n/a
total 18 20 90.0


line stmt bran cond sub pod time code
1             package Net::DNS::CloudFlare::DDNS;
2             # ABSTRACT: Object Orientated Dynamic DNS Interface to CloudFlare DNS
3              
4 1     1   115377 use Modern::Perl '2012';
  1         2  
  1         10  
5 1     1   162 use autodie ':all';
  1         1  
  1         8  
6 1     1   5615 no indirect 'fatal';
  1         3  
  1         8  
7              
8 1     1   1070 use namespace::autoclean;
  1         20457  
  1         7  
9 1     1   509 use Moose; use MooseX::StrictConstructor;
  0            
  0            
10             use Types::Standard qw( Bool Str);
11             use Net::DNS::CloudFlare::DDNS::Types qw( CloudFlareClient LWPUserAgent);
12             use TryCatch;
13             use Carp;
14             use Readonly;
15              
16             use List::Util 'shuffle';
17             use CloudFlare::Client;
18              
19             our $VERSION = '0.06_2'; # TRIAL VERSION
20              
21             has 'verbose' => ( is => 'rw', isa => Bool);
22             # CF credentials
23             has '_user' => ( is => 'ro', isa => Str, required => 1, init_arg => 'user');
24             has '_key' => ( is => 'ro', isa => Str, required => 1, init_arg => 'apikey');
25             # Configuration of zones, and their domains, to update
26             has '_config' => ( is => 'ro', required => 1, init_arg => 'zones');
27              
28             # Provides CF access
29             sub _buildApi { CloudFlare::Client::->new( user => $_[0]->_user,
30             apikey => $_[0]->_key)}
31             has '_api' => ( is => 'ro', isa => CloudFlareClient, builder => '_buildApi',
32             lazy => 1, init_arg => undef);
33              
34             # Fetch zone IDs for a single zone
35             # The api call can fail and this will die
36             # Returns a map of domain => id
37             sub _getDomainIds {
38             Readonly my $self => shift; Readonly my $zone => shift;
39              
40             # Query CloudFlare
41             say "Trying domain IDs lookup for $zone" if $self->verbose;
42             Readonly my $info => $self->_api->recLoadAll($zone);
43             # Filter to just A records and get a list of [domain => id]
44             my @pairs = map { $_->{type} eq 'A' ? [ $_->{name} => $_->{rec_id} ]
45             : () } @{ $info->{recs}{objs}};
46             # Localise hostnames to within zone, set zone itself to undef
47             map { $_->[0] eq $zone ? $_->[0] = undef : $_->[0] =~ s/\.$zone$//}
48             @pairs;
49              
50             # Build into a hash of domain => id
51             my $map; foreach (@pairs) {
52             my ($domain, $id) = @$_;
53             carp "Duplicate domain $domain found in $zone - ",
54             'this is probably a mistake' if exists $map->{$domain};
55             $map->{$domain} = $id}
56             return $map
57             }
58             # Build a mapping of zones to domain ID mappings from CF
59             # Zones will be missing if their fetch fails but the show must go on
60             sub _buildDomIds {
61             Readonly my $self => shift;
62             # $zone is a hash of config info
63             my $map; for my $zone (@{ $self->_config }) {
64             Readonly my $name => $zone->{zone};
65             Readonly my $domains => $zone->{domains};
66             # Try to fetch domain ids for this zone
67             my $zoneMap; try { $zoneMap = $self->_getDomainIds($name)}
68             catch (CloudFlare::Client::Exception::Upstream $e) {
69             carp "Fetching zone IDs for $name failed because the " ,
70             'CloudFlare API threw an error: ', $e->errorCode, ' ',
71             $e->message}
72             catch (CloudFlare::Client::Exception::Connection $e) {
73             carp "Fetching zone IDs for $zone failed because the " ,
74             'connection to the CloudFlare API failed: ', $e->status, ' ',
75             $e->message}
76             # Install ids into map under
77             $map->{\$_} = $zoneMap->{$_} foreach @$domains}
78             return $map
79             }
80             # For storing domain IDs
81             # A map of domain ref => IP
82             has '_domIds' => (
83             is => 'ro', init_arg => undef,
84             # Clear this and use lazy rebuilding to update IDs each run
85             clearer => '_clearDomIds', builder => '_buildDomIds', lazy => 1);
86              
87             # For keeping track of what we last set the IPs to
88             # A hash of domain ref -> IP
89             sub _buildLastIps { {} }
90             has _lastIps => ( is => 'rw', init_arg => undef, builder => '_buildLastIps');
91              
92             # Used for fetching the IP
93             Readonly my $UA_STRING => __PACKAGE__ . "/$VERSION";
94             sub _buildUa { Readonly my $ua => LWP::UserAgent::->new;
95             $ua->agent($UA_STRING);
96             return $ua}
97             has _ua => ( is => 'ro', isa => LWPUserAgent, builder => '_buildUa',
98             init_arg => undef);
99              
100             # Get an IP from any of a number of web services
101             # each of which return just an IP
102             Readonly my @IP_URLS => map { "http://$_" } (
103             'icanhazip.com',
104             'ifconfig.me/ip',
105             'curlmyip.com');
106             sub _getIp {
107             Readonly my $self => shift;
108             say 'Trying to get current IP' if $self->verbose;
109              
110             # Try each service till we get an IP
111             # Randomised order for balancing
112             for my $serviceUrl (shuffle @IP_URLS) {
113             say "Trying IP lookup at $serviceUrl" if $self->verbose;
114             # Get and return IP
115             Readonly my $res => $self->_ua->get($serviceUrl);
116             if($res->is_success) {
117             # Chop off the newline
118             chomp(my $ip = $res->decoded_content);
119             say "IP lookup at $serviceUrl returned $ip" if $self->verbose;
120             return $ip
121             }
122             # Else log this lookup as failing and try another service
123             carp "IP lookup at $serviceUrl failed: ", $res->status_line;
124             }
125             # All lookups have failed
126             carp 'Could not lookup IP'; return
127             }
128              
129             Readonly my $REC_TYPE => 'A';
130             Readonly my $TTL => '1';
131             sub update {
132             Readonly my $self => shift;
133             # Try to get the current IP address
134             carp "Cannot update records without an IP" and return unless
135             Readonly my $ip => $self->_getIp;
136             # Try to update each zone
137             for my $zone (@{ $self->_config }) {
138             # Try to update each domain
139             say "Trying to update records for $zone->{zone}" if $self->verbose;
140             for my $dom (@{ $zone->{domains} }) {
141             # Skip update unless there is a change in IP
142             do { no warnings 'uninitialized';
143             if ($self->_lastIps->{\$dom} eq $ip) {
144             say "IP not changed for $dom, skipping update" if
145             $self->verbose;
146             next}};
147             # Cannot update if domain ID couldn't be found
148             # At this point new domain IDs will be pulled in from CF
149             warn "Domain ID not found for $dom, cannot update" and next
150             unless defined $self->_domIds->{\$dom};
151             # Update IP
152             say "Trying to update IP for $dom" if $self->verbose;
153             try { $self->_api->recEdit($zone->{zone}, $REC_TYPE,
154             $self->_domIds->{\$dom}, $dom->{name},
155             $ip, $TTL);
156             # Record the new IP - won't happen if we fail above
157             $self->_lastIps->{\$dom} = $ip}
158             catch (CloudFlare::Client::Exception::Upstream $e) {
159             carp "Updating IP for $dom in $zone->{zone} failed ",
160             'because the CloudFlare API threw an error: ',
161             $e->errorCode, ' ', $e->message}
162             catch (CloudFlare::Client::Exception::Connection $e) {
163             carp "Updating IP for $dom in $zone->{zone} failed ",
164             'because the connection to the CloudFlare API failed: ',
165             $e->status, ' ', $e->message}}}
166             # Flush domain IDs so they're updated next run
167             $self->_clearDomIds
168             }
169              
170             1; # End of Net::DNS::CloudFlare::DDNS
171              
172             __END__
173              
174             =pod
175              
176             =encoding UTF-8
177              
178             =head1 NAME
179              
180             Net::DNS::CloudFlare::DDNS - Object Orientated Dynamic DNS Interface to CloudFlare DNS
181              
182             =head1 VERSION
183              
184             version 0.06_2
185              
186             =head1 SYNOPSIS
187              
188             Provides an object orientated dynamic DNS interface for CloudFlare
189              
190             use Net::DNS::CloudFlare::DDNS;
191              
192             my $ddns = Net::DNS::CloudFlare::DDNS->new(
193             user => $CF_USER,
194             apikey => $CF_KEY,
195             zones => $ZONE_CONF
196             );
197             my $ddns->update();
198             ...
199              
200             =head1 ATTRIBUTES
201              
202             =head2 verbose
203              
204             Whether or not the object should be verbose
205              
206             # Verbosity on
207             $ddns->verbose(1);
208              
209             # Verbosity off
210             $ddns->verbose(0);
211              
212             # Print current verbosity
213             say $ddns->verbose;
214              
215             =head1 METHODS
216              
217             =head2 new
218              
219             Create a new Dynamic DNS object
220              
221             my $ddns = Net::DNS::CloudFlare::DDNS->new(
222             # Required
223             user => $CF_USER,
224             apikey => $CF_KEY,
225             zones => $ZONE_CONF,
226             # Optional
227             verbose => $VERB_LVL
228             );
229              
230             The zones specifies the zones and records which will be updated. Its structure
231             is as follows
232              
233             # Array of
234             [
235             # Hashes of
236             {
237             # DNS Zone
238             zone => $zone_name_1,
239             # Domains to be updated in this zone
240             domains => [
241             $domain_1, ..., $domain_n
242             ]
243             },
244             ...
245             {
246             zone => $zone_name_n,
247             domains => [
248             $domain_1, ..., $domain_n
249             ]
250             }
251             ]
252              
253             Each domain is an A record within a zone or undef for the zone itself
254              
255             =head2 update
256              
257             Updates CloudFlare DNS with the current IP address if necessary
258              
259             $ddns->update
260              
261             =for test_synopsis my ($CF_USER, $CF_KEY, $ZONE_CONF);
262              
263             =head1 AUTHOR
264              
265             Peter Roberts <me+dev@peter-r.co.uk>
266              
267             =head1 COPYRIGHT AND LICENSE
268              
269             This software is Copyright (c) 2014 by Peter Roberts.
270              
271             This is free software, licensed under:
272              
273             The MIT (X11) License
274              
275             =cut