File Coverage

blib/lib/EasyDNS/DDNS.pm
Criterion Covered Total %
statement 60 78 76.9
branch 13 40 32.5
condition 7 19 36.8
subroutine 11 13 84.6
pod 0 2 0.0
total 91 152 59.8


line stmt bran cond sub pod time code
1             package EasyDNS::DDNS;
2              
3 2     2   730422 use strict;
  2         6  
  2         83  
4 2     2   11 use warnings;
  2         10  
  2         160  
5              
6             our $VERSION = '0.1.0';
7              
8 2     2   1170 use URI::Escape qw(uri_escape);
  2         4664  
  2         157  
9              
10 2     2   1193 use EasyDNS::DDNS::Config ();
  2         7  
  2         61  
11 2     2   1021 use EasyDNS::DDNS::HTTP ();
  2         6  
  2         52  
12 2     2   914 use EasyDNS::DDNS::State ();
  2         8  
  2         46  
13 2     2   12 use EasyDNS::DDNS::Util ();
  2         3  
  2         2186  
14              
15             sub new {
16 2     2 0 203346 my ($class, %args) = @_;
17             return bless {
18             verbose => $args{verbose} // 0,
19             http => $args{http}, # optional injection for tests
20 2   50     24 }, $class;
21             }
22              
23             sub cmd_update {
24 2     2 0 18 my ($self, %args) = @_;
25              
26             my $cfg = EasyDNS::DDNS::Config->load(
27             config_path => $args{config_path},
28             env => \%ENV,
29             cli => {
30             hosts => $args{hosts},
31             state_path => $args{state_path},
32             ip => $args{ip},
33             ip_url => $args{ip_url},
34             timeout => $args{timeout},
35             },
36 2         29 );
37 2 50       14 return $cfg if !$cfg->{ok};
38              
39 2         3 my $r = $cfg->{resolved};
40 2   50     7 my $token = $cfg->{secrets}{token} // '';
41 2 50       6 my $dry_run = $args{dry_run} ? 1 : 0;
42              
43 2   50     6 my $hosts = $r->{hosts} || [];
44 2 50       15 return _err(2, "No hostnames provided. Use --host or set [update] hosts in config.")
45             if !@$hosts;
46              
47 2 50       22 if (!$dry_run) {
48             return _err(2, "Missing EasyDNS username. Set EASYDNS_USER or [easydns] username.")
49 2 50       7 if !$r->{username};
50 2 50       5 return _err(2, "Missing EasyDNS token. Set EASYDNS_TOKEN or [easydns] token.")
51             if !$token;
52             }
53              
54             my $http = $self->{http} || EasyDNS::DDNS::HTTP->new(
55             timeout => $r->{timeout},
56             verbose => $self->{verbose},
57 2   33     30 );
58              
59             my $state = EasyDNS::DDNS::State->new(
60             path => $r->{state_path},
61             verbose => $self->{verbose},
62 2         18 );
63              
64 2         4 my $current_ip = $r->{ip};
65 2 50       6 if (!$current_ip) {
66 0         0 $current_ip = _fetch_public_ip($http, $r->{ip_url});
67 0 0       0 return _err(4, "Could not determine public IPv4 address") if !$current_ip;
68             } else {
69 2 50       18 return _err(2, "Invalid IPv4 address supplied via --ip")
70             if $current_ip !~ /^(\d{1,3}\.){3}\d{1,3}$/;
71             }
72              
73 2         6 my $last_ip = $state->getLastIp;
74 2 100 66     10 if ($last_ip && $last_ip eq $current_ip) {
75             return {
76 1         8 ok => 1,
77             exit_code => 0,
78             message => "No change (IP unchanged)",
79             current_ip => $current_ip,
80             resolved => $r,
81             };
82             }
83              
84 1 50       4 if ($dry_run) {
85             return {
86 0         0 ok => 1,
87             exit_code => 0,
88             message => "Dry-run: would update",
89             current_ip => $current_ip,
90             resolved => $r,
91             };
92             }
93              
94 1         3 for my $host (@$hosts) {
95 1         21 my $u = _easydns_update_url($host, $current_ip);
96              
97 1         6 my $auth = $http->basicAuthHeader($r->{username}, $token);
98              
99 1         11 my $resp = $http->get($u, headers => { Authorization => $auth }, desc => "EasyDNS update $host");
100 1   50     21 my $body = EasyDNS::DDNS::Util::trim($resp->{content} // '');
101              
102 1         5 my $parsed = _parse_easydns_response($body);
103 1 50       8 if (!$parsed->{ok}) {
104 0         0 return _err($parsed->{exit_code}, "EasyDNS update failed for $host: $parsed->{code}");
105             }
106             }
107              
108 1         6 $state->setLastIp($current_ip);
109              
110             return {
111 1         22 ok => 1,
112             exit_code => 0,
113             message => "Updated",
114             current_ip => $current_ip,
115             resolved => $r,
116             };
117             }
118              
119             sub _fetch_public_ip {
120 0     0   0 my ($http, $url) = @_;
121 0         0 my $resp = $http->get($url, desc => "IP discovery");
122 0   0     0 my $body = EasyDNS::DDNS::Util::trim($resp->{content} // '');
123 0 0       0 return '' if $body !~ /^(\d{1,3}\.){3}\d{1,3}$/;
124 0         0 return $body;
125             }
126              
127             sub _easydns_update_url {
128 1     1   4 my ($host, $ip) = @_;
129 1         6 my $h = uri_escape($host);
130 1         83 my $i = uri_escape($ip);
131 1         20 return "https://api.cp.easydns.com/dyn/generic.php?hostname=$h&myip=$i";
132             }
133              
134             sub _parse_easydns_response {
135 1     1   3 my ($body) = @_;
136 1         24 $body = EasyDNS::DDNS::Util::trim($body);
137 1         15 my $u = uc $body;
138              
139 1 50       15 return { ok => 1, code => 'OK', exit_code => 0 } if $u =~ /\bOK\b/;
140 0 0         return { ok => 1, code => 'NOERROR', exit_code => 0 } if $u =~ /\bNOERROR\b/;
141              
142 0 0         return { ok => 0, code => 'NOACCESS', exit_code => 3 } if $u =~ /\bNOACCESS\b/;
143 0 0 0       return { ok => 0, code => 'NO_AUTH', exit_code => 3 } if $u =~ /\bNO_AUTH\b/ || $u =~ /\bNOAUTH\b/;
144              
145 0 0         return { ok => 0, code => 'TOOSOON', exit_code => 5 } if $u =~ /\bTOOSOON\b/;
146 0 0         return { ok => 0, code => 'NOSERVICE', exit_code => 5 } if $u =~ /\bNOSERVICE\b/;
147 0 0         return { ok => 0, code => 'ILLEGAL', exit_code => 5 } if $u =~ /\bILLEGAL\b/;
148              
149 0           return { ok => 0, code => 'UNKNOWN', exit_code => 5 };
150             }
151              
152             sub _err {
153 0     0     my ($exit_code, $msg) = @_;
154 0           return { ok => 0, exit_code => $exit_code, error => $msg };
155             }
156              
157             1;
158