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   3078551 use warnings;
  12         129  
  12         367  
5 12     12   67 use Getopt::Long;
  12         32  
  12         353  
6 12     12   8871 use Pod::Usage;
  12         145460  
  12         62  
7 12     12   8018 use DateTime;
  12         588239  
  12         1630  
8 12     12   11732 use DateTime::Format::Strptime;
  12         6441365  
  12         629  
9 12     12   8150 use Data::Dumper;
  12         2473807  
  12         68  
10 12     12   10635 use Data::Validate::Email qw(is_email);
  12         81003  
  12         941  
11 12     12   5619 use Data::Validate::IP;
  12         249544  
  12         897  
12 12     12   6280 use Email::Sender::Transport::SMTP;
  12         392321  
  12         1889  
13 12     12   6064 use Email::Stuffer;
  12         1832579  
  12         566  
14 12     12   4882 use LWP::Online 'online';
  12         361141  
  12         478  
15 12     12   5129 use LWP::UserAgent;
  12         581474  
  12         103  
16 12     12   1675 use Socket qw(:addrinfo SOCK_RAW);
  12         31  
  12         373  
17 12     12   75 use Text::CSV qw(csv);
  12         26  
  12         3661  
18 12     12   5605 use feature 'say';
  12         111489  
  12         837  
19 12     12   129 our $VERSION = '1.0.4';
  12         152  
  12         17431  
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 2025533 if ($opt_4 == $opt_6) {
93 6         32 push @list, $ip4, $ip6;
94 6         18 } elsif ($opt_4) {
95 6 100       25 push @list, $ip4;
    100          
96 4         22 } else {
97             push @list, $ip6;
98 1         4 }
99             for my $ip (@list) {
100 1         4 my ($latest, $overdue) = last_ip($ip, $aoaref);
101             if (!$latest or $overdue) {
102 6         21 send_email($ip, "$dnsname has moved to $ip")
103 10         139 if defined $opt_email and scalar @$opt_email;
104 10 100 100     67 }
105 8 50 50     78 }
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 8214  
113 6         18 # Returns two booleans. The first indicates whether the IP address passed in
114 6 100       23 # 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 2370 if ((valid4($$line[0]) and $v4)
122 20 100       70 or (valid6($$line[0]) and !$v4)) {
123 18         51 $lastip = $$line[0];
124 18         298 $lasttime = $strp->parse_datetime($$line[1]);
125 18         54 last;
126 30 100 66     380 }
      66        
      100        
127             }
128 18         439 if ($lastip eq $ip) {
129 18         146 # This is the latest IP address of its type
130 18         20248 $opt_leeway //= 0;
131             my $dt = DateTime->now;
132             my $overdue = $dt->epoch > ($lasttime->epoch + $opt_leeway);
133 18 100       70 return 1, $overdue;
134             } else {
135 8   100     42 return 0, 0;
136 8         34 }
137 8         2673 }
138 8         220  
139             my ($ip) = @_;
140 10         84 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 2064 my @fields = ($ip, $timestamp);
146 7 100   2   386 $csv->say($fh, \@fields);
  2         15  
  2         4  
  2         14  
147             close $fh or die "Unable to close $opt_file: $!";
148 6         1492 send_email($ip) if defined $opt_email and scalar @$opt_email;
149 6         2308 }
150 6         358  
151 6         759 return csv (in => $opt_file);
152 6         31 }
153 6 50       834  
154 6 0 50     115 no warnings 'uninitialized';
155             say "Help: >$opt_help<";
156             say "Man: >$opt_man<";
157             say "Versions: >$opt_versions<";
158 4     4 0 1930 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   106 say "Server: >$opt_server<";
  12         27  
  12         3204  
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   98 die "Invalid option combination - mailport is $opt_mailport but mailserver is unspecified"
  12         30  
  12         16673  
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 2190 $opt_server ||= '';
195 2         13 $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         15 $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 357 for my $address (@$opt_email) {
204 9         17 $params{'to'} = $address;
205 9 50       29 my $stuffer = Email::Stuffer->new(\%params);
206 9   100     44 $stuffer->send;
207 9   33     29 }
208 9   66     27 } else {
209 9         18 $params{'to'} = $opt_email;
210 9         40 my $stuffer = Email::Stuffer->new(\%params);
211 9         30 $stuffer->send;
212 9         22 }
213 9 50       25 }
214 9 50 33     51  
215 9         33 my $ip = shift;
216 9         25 return 0 if is_unroutable_ipv4($ip);
217 9         97 return 0 if is_private_ipv4($ip);
218 9         43954 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 3566 return 0 if is_loopback_ipv6($ip);
229 64 100       2046 return 0 if is_linklocal_ipv6($ip);
230 63 100       3991 return 0 if is_multicast_ipv6($ip);
231 62 100       3393 return 0 if is_ipv4_mapped_ipv6($ip);
232 61 100       3348 return 0 if is_discard_ipv6($ip);
233 60 100       3574 return 0 if is_special_ipv6($ip);
234 59 100       3146 return 0 if is_documentation_ipv6($ip);
235 58 100       2939 return 1 if is_ipv6($ip);
236 57 100       2042 }
237              
238             return get_ip('http://ip6only.me/api/');
239             }
240 30     30 0 4959  
241 30 100       633 return get_ip('http://ip4only.me/api/');
242 29 100       1796 }
243 28 100       1605  
244 27 100       1531 my $url = shift;
245 26 100       1462 my $ua = LWP::UserAgent->new;
246 25 100       1461 my $req = HTTP::Request->new(GET => $url);
247 24 100       1361 my $res = $ua->request($req);
248 23 100       1273 my $csv = $res->content;
249 22 50       923 my $aoa = csv(in => \$csv);
250             return $$aoa[0][1];
251             }
252              
253 1     1 0 138 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 333 if (valid4($ip1)) {
258             return $ip1, $ip2;
259             } else {
260             return $ip2, $ip1;
261 2     2 0 11 }
262 2         22 }
263 2         832  
264 2         477 END {
265 2         573010 if(defined $opt_versions){
266 2         66 print
267 2         3670 "\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 7876 " Data::Validate::Email $Data::Validate::Email::VERSION\n",
272 8         488947 " Data::Validate::IP $Data::Validate::IP::VERSION\n",
273 8         231 " DateTime $DateTime::VERSION\n",
274 8         80 " DateTime::Format::Strptime $DateTime::Format::Strptime::VERSION\n",
275 8 50       53 " Email::Sender::Transport::SMTP $Email::Sender::Transport::SMTP::VERSION\n",
276 8         287 " 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   3585667 " 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).