File Coverage

lib/App/ipchgmon.pm
Criterion Covered Total %
statement 150 193 77.7
branch 56 86 65.1
condition 22 55 40.0
subroutine 33 37 89.1
pod 0 17 0.0
total 261 388 67.2


line stmt bran cond sub pod time code
1             #!/usr/bin/perl
2              
3             use strict;
4 12     12   2836530 use warnings;
  12         134  
  12         320  
5 12     12   57 use Getopt::Long;
  12         27  
  12         313  
6 12     12   7958 use Pod::Usage;
  12         130076  
  12         59  
7 12     12   7193 use DateTime;
  12         530599  
  12         1582  
8 12     12   11060 use DateTime::Format::Strptime;
  12         5808793  
  12         618  
9 12     12   7886 use Data::Dumper;
  12         2195320  
  12         70  
10 12     12   9623 use Data::Validate::Email qw(is_email);
  12         71067  
  12         857  
11 12     12   5099 use Data::Validate::IP;
  12         221061  
  12         777  
12 12     12   5635 use Email::Sender::Transport::SMTP;
  12         340919  
  12         1665  
13 12     12   5505 use Email::Stuffer;
  12         1588634  
  12         473  
14 12     12   4212 use LWP::Online 'online';
  12         314373  
  12         472  
15 12     12   4922 use LWP::UserAgent;
  12         523581  
  12         96  
16 12     12   1526 use Socket qw(:addrinfo SOCK_RAW);
  12         28  
  12         342  
17 12     12   66 use Text::CSV qw(csv);
  12         26  
  12         3003  
18 12     12   4947 use feature 'say';
  12         100337  
  12         750  
19 12     12   121 our $VERSION = '1.0.7';
  12         140  
  12         15523  
20              
21             my $TIMEFORMAT = '%FT%T%z';
22             my $strp = DateTime::Format::Strptime->new(on_error => 'croak',
23             pattern => $TIMEFORMAT,
24             );
25              
26             our ($opt_help, $opt_man, $opt_versions,
27             $opt_debug, $opt_singleemail, $opt_4, $opt_6,
28             $opt_email, $opt_file, $opt_mailserver, $opt_mailport, $opt_leeway,
29             $opt_mailfrom, $opt_mailsubject, $opt_server, $opt_dnsname,
30             );
31              
32             GetOptions(
33             'help!' => \$opt_help,
34             'man!' => \$opt_man,
35             'versions!' => \$opt_versions,
36            
37             'debug!' => \$opt_debug,
38             'singleemail!' => \$opt_singleemail,
39             '4!' => \$opt_4,
40             '6!' => \$opt_6,
41            
42             'email|mailto=s@' => \$opt_email,
43             'file=s' => \$opt_file,
44             'server=s' => \$opt_server,
45             'mailserver=s' => \$opt_mailserver,
46             'mailport=i' => \$opt_mailport,
47             'leeway=i' => \$opt_leeway,
48             'mailfrom=s' => \$opt_mailfrom,
49             'mailsubject=s' => \$opt_mailsubject,
50             'dnsname=s' => \$opt_dnsname,
51             ) or pod2usage(-verbose => 1) && exit;
52              
53             pod2usage(-verbose => 1) && exit if defined $opt_help;
54             pod2usage(-verbose => 2) && exit if defined $opt_man;
55              
56             unless (caller()) {
57             if (!defined $opt_file and !$opt_debug) {
58             pod2usage(-verbose => 1);
59             exit;
60             }
61             if (!defined $opt_server and !$opt_debug) {
62             pod2usage(-verbose => 1);
63             exit;
64             }
65             main();
66             }
67              
68             dump_options() if $opt_debug;
69             validate_email() if defined $opt_email and scalar @$opt_email;
70 0 0   0 0 0 validate_transport() if defined $opt_mailport or defined $opt_mailserver;
71 0 0 0     0 $opt_4 ||= 0;
72 0 0 0     0 $opt_6 ||= 0;
73 0   0     0 unless (online) {
74 0   0     0 $opt_mailsubject = "Internet connection lost for $opt_server";
75 0 0       0 send_email($opt_server, 'No internet connection');
76 0         0 exit;
77 0         0 }
78 0         0 my $aoaref;
79             $aoaref = read_file() if -e $opt_file;
80 0         0 my $ip4 = get_ip4();
81 0 0       0 check_changes($ip4, $aoaref) if $ip4 and ($opt_4 or !$opt_6);
82 0         0 my $ip6 = get_ip6();
83 0 0 0     0 check_changes($ip6, $aoaref) if $ip6 and ($opt_6 or !$opt_4);
      0        
84 0         0 $aoaref = read_file();
85 0 0 0     0 check_dns($opt_dnsname, $aoaref) if $opt_dnsname;
      0        
86 0         0 exit;
87 0 0       0 }
88 0         0  
89             my ($dnsname, $aoaref) = @_;
90             my ($ip4, $ip6) = nslookup($dnsname);
91             my @list;
92 6     6 0 2025524 if ($opt_4 == $opt_6) {
93 6         27 push @list, $ip4, $ip6;
94 6         15 } elsif ($opt_4) {
95 6 100       22 push @list, $ip4;
    100          
96 4         15 } else {
97             push @list, $ip6;
98 1         4 }
99             for my $ip (@list) {
100 1         3 my ($latest, $overdue) = last_ip($ip, $aoaref);
101             if (!$latest or $overdue) {
102 6         18 send_email($ip, "$dnsname has moved to $ip")
103 10         137 if defined $opt_email and scalar @$opt_email;
104 10 100 100     55 }
105 8 50 50     81 }
106             }
107              
108             my ($ip, $aoaref) = @_;
109             my ($latest, $overdue) = last_ip($ip, $aoaref);
110             new_ip($ip) if !$latest;
111             }
112 6     6 0 7608  
113 6         13 # Returns two booleans. The first indicates whether the IP address passed in
114 6 100       19 # is the latest of its type in the AoA. The second indicates whether the leeway
115             # has passed.
116             my ($ip, $aoaref) = @_;
117             return 0, 0 unless defined $aoaref;
118             my $v4 = valid4($ip);
119             my ($lastip, $lasttime);
120             for my $line (reverse @$aoaref) {
121 20     20 0 1961 if ((valid4($$line[0]) and $v4)
122 20 100       61 or (valid6($$line[0]) and !$v4)) {
123 18         37 $lastip = $$line[0];
124 18         256 $lasttime = $strp->parse_datetime($$line[1]);
125 18         45 last;
126 30 100 66     316 }
      66        
      100        
127             }
128 18         354 if ($lastip eq $ip) {
129 18         109 # This is the latest IP address of its type
130 18         16410 $opt_leeway //= 0;
131             my $dt = DateTime->now;
132             my $overdue = $dt->epoch > ($lasttime->epoch + $opt_leeway);
133 18 100       60 return 1, $overdue;
134             } else {
135 8   100     30 return 0, 0;
136 8         29 }
137 8         2093 }
138 8         150  
139             my ($ip) = @_;
140 10         68 open my $fh, '>>:encoding(utf8)', $opt_file
141             or die "Unable to append to $opt_file: $!";
142             my $dt = DateTime->now;
143             my $timestamp = $dt->rfc3339;
144             my $csv = Text::CSV->new();
145 7     7 0 1415 my @fields = ($ip, $timestamp);
146 7 100   2   323 $csv->say($fh, \@fields);
  2         11  
  2         4  
  2         12  
147             close $fh or die "Unable to close $opt_file: $!";
148 6         1119 send_email($ip) if defined $opt_email and scalar @$opt_email;
149 6         1769 }
150 6         241  
151 6         611 return csv (in => $opt_file);
152 6         26 }
153 6 50       693  
154 6 0 50     84 no warnings 'uninitialized';
155             say "Help: >$opt_help<";
156             say "Man: >$opt_man<";
157             say "Versions: >$opt_versions<";
158 4     4 0 1453 say "Single email: >$opt_singleemail<";
159             say "4: >$opt_4<";
160             say "6: >$opt_6<";
161 0         0 say "File: >$opt_file<";
162 12     12   93 say "Server: >$opt_server<";
  12         40  
  12         2828  
163 0     0 0 0 say "DNS name: >$opt_dnsname<";
164 0         0 say "Mail Server: >$opt_mailserver<";
165 0         0 say "Mail Port: >$opt_mailport<";
166 0         0 say "Mail From: >$opt_mailfrom<";
167 0         0 say "Mail Subject: >$opt_mailsubject<";
168 0         0 say "Leeway: >$opt_leeway<";
169 0         0 print "Email addresses: ";
170 0         0 print Dumper $opt_email;
171 0         0 use warnings 'uninitialized';
172 0         0 }
173 0         0  
174 0         0 for my $address (@$opt_email) {
175 0         0 die "Invalid email address: $address" unless is_email($address);
176 0         0 }
177 0         0 }
178 0         0  
179 12     12   115 die "Invalid option combination - mailport is $opt_mailport but mailserver is unspecified"
  12         31  
  12         14376  
180             if defined $opt_mailport and !defined $opt_mailserver;
181             }
182              
183 0     0 0 0 $opt_mailport ||= 25;
184 0 0       0 my $transport = Email::Sender::Transport::SMTP->new({
185             host => $opt_mailserver,
186             port => $opt_mailport,
187             });
188             return $transport;
189 0 0 0 0 0 0 }
190              
191             my ($ip, $body) = @_;
192             my $transport;
193             $transport = build_transport if defined $opt_mailserver;
194 2   100 2 0 2152 $opt_server ||= '';
195 2         12 $opt_mailsubject ||= $opt_server . ' has a new address';
196             $body ||= "$opt_server is now at $ip";
197             my %params;
198             $params{'from'} = $opt_mailfrom;
199 2         16 $params{'subject'} = $opt_mailsubject;
200             $params{'text_body'} = $body;
201             $params{'transport'} = $transport if defined $transport;
202             if ($opt_singleemail or 1 == scalar(@$opt_email)) {
203 9     9 0 329 for my $address (@$opt_email) {
204 9         16 $params{'to'} = $address;
205 9 50       25 my $stuffer = Email::Stuffer->new(\%params);
206 9   100     40 $stuffer->send;
207 9   33     41 }
208 9   66     31 } else {
209 9         15 $params{'to'} = $opt_email;
210 9         27 my $stuffer = Email::Stuffer->new(\%params);
211 9         23 $stuffer->send;
212 9         20 }
213 9 50       24 }
214 9 50 33     54  
215 9         31 my $ip = shift;
216 9         24 return 0 if is_unroutable_ipv4($ip);
217 9         96 return 0 if is_private_ipv4($ip);
218 9         40517 return 0 if is_loopback_ipv4($ip);
219             return 0 if is_linklocal_ipv4($ip);
220             return 0 if is_testnet_ipv4($ip);
221 0         0 return 0 if is_anycast_ipv4($ip);
222 0         0 return 0 if is_multicast_ipv4($ip);
223 0         0 return 1 if is_ipv4($ip);
224             }
225              
226             my $ip = shift;
227             return 0 if is_private_ipv6($ip);
228 64     64 0 3713 return 0 if is_loopback_ipv6($ip);
229 64 100       1410 return 0 if is_linklocal_ipv6($ip);
230 63 100       3236 return 0 if is_multicast_ipv6($ip);
231 62 100       2815 return 0 if is_ipv4_mapped_ipv6($ip);
232 61 100       2769 return 0 if is_discard_ipv6($ip);
233 60 100       2656 return 0 if is_special_ipv6($ip);
234 59 100       2677 return 0 if is_documentation_ipv6($ip);
235 58 100       2611 return 1 if is_ipv6($ip);
236 57 100       1710 }
237              
238             return get_ip('http://ip6only.me/api/');
239             }
240 30     30 0 5391  
241 30 100       604 return get_ip('http://ip4only.me/api/');
242 29 100       1667 }
243 28 100       1403  
244 27 100       1364 my $url = shift;
245 26 100       1279 my $ua = LWP::UserAgent->new;
246 25 100       1201 my $req = HTTP::Request->new(GET => $url);
247 24 100       1181 my $res = $ua->request($req);
248 23 100       1091 my $csv = $res->content;
249 22 50       780 my $aoa = csv(in => \$csv);
250             return $$aoa[0][1];
251             }
252              
253 1     1 0 126 my $hostname = shift;
254             my ($err, $v1, $v2) = getaddrinfo($hostname, "", { socktype => SOCK_RAW });
255             my (undef, $ip1) = getnameinfo($v1->{addr}, NI_NUMERICHOST, NIx_NOSERV);
256             my (undef, $ip2) = getnameinfo($v2->{addr}, NI_NUMERICHOST, NIx_NOSERV);
257 1     1 0 67 if (valid4($ip1)) {
258             return $ip1, $ip2;
259             } else {
260             return $ip2, $ip1;
261 2     2 0 6 }
262 2         18 }
263 2         620  
264 2         349 END {
265 2         192868 if(defined $opt_versions){
266 2         41 print
267 2         2582 "\nModules, Perl, OS, Program info:\n",
268             " Getopt::Long $Getopt::Long::VERSION\n",
269             " Pod::Usage $Pod::Usage::VERSION\n",
270             " Data::Dumper $Data::Dumper::VERSION\n",
271 8     8 0 9201 " Data::Validate::Email $Data::Validate::Email::VERSION\n",
272 8         197865 " Data::Validate::IP $Data::Validate::IP::VERSION\n",
273 8         152 " DateTime $DateTime::VERSION\n",
274 8         67 " DateTime::Format::Strptime $DateTime::Format::Strptime::VERSION\n",
275 8 50       37 " Email::Sender::Transport::SMTP $Email::Sender::Transport::SMTP::VERSION\n",
276 8         209 " Email::Stuffer $Email::Stuffer::VERSION\n",
277             " LWP::Online $LWP::Online::VERSION\n",
278 0         0 " LWP::UserAgent $LWP::UserAgent::VERSION\n",
279             " Socket $Socket::VERSION\n",
280             " Text::CSV $Text::CSV::VERSION\n",
281             " strict $strict::VERSION\n",
282             " Perl $]\n",
283 12 50   12   3516637 " OS $^O\n",
284 0         0 " $0 $VERSION\n",
285             "\n\n";
286             }
287             }
288              
289             1;
290              
291             =pod
292              
293             =head1 NAME
294              
295             ipchgmon.pm - Watches for changes to public facing IP addresses
296              
297             =head1 SYNOPSIS
298              
299             perl ipchgmon.pm --file c:\data\log.txt --server example.com
300              
301             Who knows? It might even work. More usually, in a cron job:
302              
303             perl ipchgmon --file ~/log.txt \
304             --server back_office_top_shelf \
305             --dnsname example.com \
306             --leeway 86400 \
307             --email serverchange@example.com \
308             --mailserver 192.168.0.2 \
309             --mailfrom ipchgmon@example.com \
310             --mailsubject 'Change of IP address'
311              
312             =head1 BACKGROUND
313              
314             I and friends run email and other servers at home. I pay for a static IP
315             address with the clause in my contract that force majeur may require a
316             change. Others are on dynamic addresses. Should the public facing address
317             change, we want to know. This modulino is intended to monitor the public
318             IP address and shout for help should the address change. One friend is
319             looking at code to change public DNS records automatically.
320              
321             =head1 DESCRIPTION
322              
323             This modulino is intended to be run automatically as a cron job. It should
324             check whether the server running it has changed its IP address. If so,
325             messages should be sent to those specified.
326              
327             Either IPv4 or IPv6 or both formats can be tested. It might well be that
328             different servers handle the different types.
329              
330             There are three issues that may be checked. The first is connectivity. If
331             there is no connectivity, messages should be sent. No further issues will be
332             tested.
333              
334             The second issue that will be tested is whether the IP address has changed.
335             The current public-facing IP address is established via internet use and
336             compared to a log file, specified by the "--file" option. If it is not the
337             last entry in the log, messages will be sent.
338              
339             Finally and optionally, the DNS name of the server will be used to get
340             another IP address from global DNS. If this is not the last address in the
341             log file, messages will be send unless a leeway has been specified and this
342             has not expired.
343              
344             If connectivity is lost, the number of retries and the wait will both be
345             options in a later version. At present, or if the retries all fail, someone
346             should be sent a message.
347              
348             The design includes three forms of message, SMS, HTTP and email. It is
349             reasonable to send an email if internet connectivity is lost; the server
350             may be internal. SMS may be harder to justify. HTTP must depend on the
351             location of the server.
352              
353             There is currently no facility to send different classes of message to
354             different addresses. There is nothing inherently impossible about writing
355             code to do this if it proves desirable.
356              
357             If the IP address has changed, messages should be sent without delay or retries.
358              
359             =head1 ON ARGUMENTS AND OPTIONS
360              
361             These are processed by Getopt::Long. This means that shorter versions may
362             be used. If the first letter of an argument or option is unique, the call
363             may be reduced to a single minus sign and the first letter. So the "email"
364             argument has an alias, "mailto". But the alias must be specified as
365             "--mailt" at least, while "email" can be reduced to "-e".
366              
367             =head1 ARGUMENTS
368              
369             --file filename THIS OPTION IS COMPULSORY unless --debug is used.
370             The file "filename" will be created if it does not exist.
371             It may be preferable to use a fully qualified filename.
372             Attempting to write to a non-existent directory or without
373             the necessary permissions will cause an error. The file is
374             plain text containing the IPv4 and IPv6 addresses of the server.
375             --server name THIS OPTION IS COMPULSORY unless --debug is used.
376             It is the name that will be used to identify the machine
377             should internet connectivity be lost or the IP address
378             change. It is not used internally and should not be
379             confused with the dnsname.
380             --dnsname name The name of the server in global DNS.
381             --leeway time This option specifies how many seconds should elapse from
382             an IP address changing on DNS to a second email being sent.
383             It is used only in conjunction with dnsname. If dnsname
384             is unspecified, the value of this option is meaningless.
385             It isn't compulsory, but without it, messages will be sent
386             every time the modulino finds the IP address is not the
387             same as returned by DNS. This is fine if you like getting
388             up every hour during the night. Otherwise, use something
389             like 86400 (one day).
390             --email address Multiple instances of this option are acceptable. An email
391             will be sent to each, if possible.
392             --mailto Synonym for --email.
393             --mailserver Must be followed by the name or ip address of the outbound
394             server. Some systems may have a default for this.
395             --mailport Must be numeric. Will default to 25 if omitted.
396             --mailfrom Most servers will insist on this, but some systems may
397             have a default.
398             --mailsubject Can be omitted if desired. A default would be created
399             which would be different for each message type. Subjects
400             that include spaces would need quoting. The quote character
401             can be OS dependent.
402              
403             =head1 OPTIONS
404              
405             --help Brief manual
406             --man Full manual
407             --versions Code info
408             --debug Debugging information
409             --singleemail Sends one email at a time. Prevents multiple email
410             addresses appearing in each email and prevents server
411             confusion if different mechanisms are used for different
412             destinations. Yes, it can happen.
413             --4 Check IPv4 addresses. Both will be checked if neither
414             or both options are used.
415             --6 Check IPv6 addresses. Both will be checked if neither
416             or both options are used.
417              
418             =head1 TO DO
419              
420             =over
421              
422             * Implement SMS messages
423              
424             * Implement HTTP messages
425              
426             * implement config file
427              
428             =back
429              
430             =head1 COPYRIGHT AND LICENSE
431              
432             Copyright (C) 2022 by John Davies
433              
434             This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.
435              
436             =head1 CREDITS
437              
438             https://www.perlmonks.org/?node_id=155288 for the getopt/usage model.
439              
440             Fergus McMenemie for the talk on modulinos (https://www.youtube.com/watch?v=wCW4tpMgdHs).
441              
442             Corion (https://www.perlmonks.org/?node=Corion) for solving a naming blunder of mine.
443              
444             Slaven Rezic of the CPAN testers for taking the trouble to raise an issue that was causing lots of tester reports and suggesting a solution I would never have found alone.
445              
446             Pryrt (https://www.perlmonks.org/?node=pryrt) for spotting that I was ignoring the real cause of the problem Slaven had reported.
447              
448             Hv (https://www.perlmonks.org/?node=hv) for patiently, despite my stupidity, showing me how to emulate the testers' issue on my local machine.
449