File Coverage

blib/lib/Net/DNS/CloudFlare/DDNS.pm
Criterion Covered Total %
statement 97 134 72.3
branch 11 40 27.5
condition 1 4 25.0
subroutine 26 26 100.0
pod 1 1 100.0
total 136 205 66.3


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   107369 use Modern::Perl '2012';
  1         2  
  1         7  
5 1     1   128 use autodie ':all';
  1         1  
  1         6  
6 1     1   5745 no indirect 'fatal';
  1         3  
  1         6  
7 1     1   636 use namespace::autoclean;
  1         15046  
  1         5  
8              
9 1     1   759 use Moose; use MooseX::StrictConstructor;
  1         395921  
  1         8  
  1         6687  
  1         18885  
  1         6  
10 1     1   9511 use Types::Standard qw( Bool Str);
  1         59991  
  1         15  
11 1     1   2264 use Net::DNS::CloudFlare::DDNS::Types qw( CloudFlareClient LWPUserAgent);
  1         3  
  1         12  
12 1     1   1176 use TryCatch;
  1         403947  
  1         7  
13 1     1   546 use Carp;
  1         2  
  1         80  
14 1     1   5 use Readonly;
  1         1  
  1         51  
15              
16 1     1   5 use List::Util 'shuffle';
  1         2  
  1         147  
17 1     1   642 use CloudFlare::Client;
  1         659095  
  1         722  
18              
19             our $VERSION = '0.06_3'; # 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 1     1   3 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 2     2   68 Readonly my $self => shift; Readonly my $zone => shift;
  2         300  
39              
40             # Query CloudFlare
41 2 50       228 say "Trying domain IDs lookup for $zone" if $self->verbose;
42 2         22 Readonly my $info => $self->_api->recLoadAll($zone);
43             # Filter to just A records and get a list of [domain => id]
44 0 0       0 my @pairs = map { $_->{type} eq 'A' ? [ $_->{name} => $_->{rec_id} ]
  0         0  
45 0         0 : () } @{ $info->{recs}{objs}};
46             # Localise hostnames to within zone, set zone itself to undef
47 0 0       0 map { $_->[0] eq $zone ? $_->[0] = undef : $_->[0] =~ s/\.$zone$//}
  0         0  
48             @pairs;
49              
50             # Build into a hash of domain => id
51 0         0 my $map; foreach (@pairs) {
  0         0  
52 0         0 my ($domain, $id) = @$_;
53 0 0       0 carp "Duplicate domain $domain found in $zone - ",
54             'this is probably a mistake' if exists $map->{$domain};
55 0         0 $map->{$domain} = $id}
56 0         0 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 1     1   17 Readonly my $self => shift;
62             # $zone is a hash of config info
63 1         81 my $map; for my $zone (@{ $self->_config }) {
  1         3  
  1         2  
64 2         99 Readonly my $name => $zone->{zone};
65 2         287 Readonly my $domains => $zone->{domains};
66             # Try to fetch domain ids for this zone
67 1     1   384 my $zoneMap; try { $zoneMap = $self->_getDomainIds($name)}
  2         197  
  2         15  
  2         6  
  2         4  
  2         8  
  2         10  
68 1 50   1   610474 catch (CloudFlare::Client::Exception::Upstream $e) {
  2 0       1342883  
  2 50       65  
  2         1844  
  2         452  
69 2         16 carp "Fetching zone IDs for $name failed because the " ,
70             'CloudFlare API threw an error: ', $e->errorCode, ' ',
71             $e->message}
72 1     1   19008 catch (CloudFlare::Client::Exception::Connection $e) {
  0         0  
73 0         0 carp "Fetching zone IDs for $name failed because the " ,
74             'connection to the CloudFlare API failed: ', $e->status, ' ',
75             $e->message}
76 0         0 # Install ids into map under
  0         0  
  2         1577  
  2         14  
77 1         55 $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 2     2   3024 # 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 2     2   1819 Readonly my $UA_STRING => __PACKAGE__ . "/$VERSION";
94 2         3892 sub _buildUa { Readonly my $ua => LWP::UserAgent::->new;
95 2         106 $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 1     1   22 sub _getIp {
107 1 50       78 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 1         11 # Randomised order for balancing
112 1 50       14 for my $serviceUrl (shuffle @IP_URLS) {
113             say "Trying IP lookup at $serviceUrl" if $self->verbose;
114 1         19 # Get and return IP
115 1 50       382591 Readonly my $res => $self->_ua->get($serviceUrl);
116             if($res->is_success) {
117 1         20 # Chop off the newline
118 1 50       1900 chomp(my $ip = $res->decoded_content);
119 1         51 say "IP lookup at $serviceUrl returned $ip" if $self->verbose;
120             return $ip
121             }
122 0         0 # Else log this lookup as failing and try another service
123             carp "IP lookup at $serviceUrl failed: ", $res->status_line;
124             }
125 0         0 # All lookups have failed
126 0         0 carp 'Could not lookup IP'; return
127             }
128              
129             Readonly my $REC_TYPE => 'A';
130             Readonly my $TTL => '1';
131 1     1 1 785 sub update {
132             Readonly my $self => shift;
133 1 50 0     113 # 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 1         114 # Try to update each zone
  1         5  
137             for my $zone (@{ $self->_config }) {
138 2 50       68 # Try to update each domain
139 2         13 say "Trying to update records for $zone->{zone}" if $self->verbose;
  2         8  
140             for my $dom (@{ $zone->{domains} }) {
141 1     1   1133 # Skip update unless there is a change in IP
  1         2  
  1         312  
  4         124  
142 4 50       10 do { no warnings 'uninitialized';
143 0 0       0 if ($self->_lastIps->{\$dom} eq $ip) {
144             say "IP not changed for $dom, skipping update" if
145 0         0 $self->verbose;
146             next}};
147             # Cannot update if domain ID couldn't be found
148 4 50 50     41 # 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 0 0       0 # Update IP
152 1     1   239 say "Trying to update IP for $dom" if $self->verbose;
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
153             try { $self->_api->recEdit($zone->{zone}, $REC_TYPE,
154             $self->_domIds->{\$dom}, $dom,
155             $ip, $TTL);
156 0         0 # Record the new IP - won't happen if we fail above
157 1 0   1   23143 $self->_lastIps->{\$dom} = $ip}
  0 0       0  
  0 0       0  
  0         0  
  0         0  
158 0         0 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 1     1   20515 $e->errorCode, ' ', $e->message}
  0         0  
162 0         0 catch (CloudFlare::Client::Exception::Connection $e) {
163             carp "Updating IP for $dom in $zone->{zone} failed ",
164 0         0 'because the connection to the CloudFlare API failed: ',
  0         0  
  0         0  
165             $e->status, ' ', $e->message}}}
166             # Flush domain IDs so they're updated next run
167 1         58 $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_3
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 BUGS
264              
265             Please report any bugs or feature requests to C<bug-net-dns-cloudflare-ddns
266             at rt.cpan.org>, or through the web interface at
267             L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Net-DNS-CloudFlare-DDNS>
268              
269             =head1 AUTHOR
270              
271             Peter Roberts <me+dev@peter-r.co.uk>
272              
273             =head1 COPYRIGHT AND LICENSE
274              
275             This software is Copyright (c) 2014 by Peter Roberts.
276              
277             This is free software, licensed under:
278              
279             The MIT (X11) License
280              
281             =cut