#!/usr/bin/env perl
# @(#) DnsPixie.pl  Update client for FreeDNS, DuckDNS and other popular
#                   dynamic DNS services. Rev'd: 2023-01-14
#
# Copyright (c) 2014 Graham Jenkins <grahjenk@cpan.org>. All rights reserved.
# This program is free software; you can redistribute it and/or modify it under
# the same terms as Perl itself.

use strict;
use warnings;
use File::Basename;
use Getopt::Std;
use Sys::Syslog;
use Net::IP qw(:PROC);           # Use whichever Curl module is available
BEGIN { eval { require Net::Curl::Compat } }
use WWW::Curl::Easy;
use vars qw($VERSION);
$VERSION=1.29;
$Getopt::Std::STANDARD_HELP_VERSION=1;

# Collect options, check usage, open configuration file
my ($Mode,$BadOption,%opts)=("IPv4",,);
getopts ('dxvm:l:',\%opts) or $BadOption="Y";
if ( ($#ARGV != 0) || defined($BadOption) ||
     (defined($opts{'m'}) && ($opts{'m'} !~ m/^\d+$/)) ) {
  die "Usage: ".basename($0)." [-x] [-d] [-v] [-m N] [-l led] ".
              "config-file\n".  "Refer: perldoc ".basename($0)."\n"
}
if ( defined($opts{'m'})&&($opts{'m'}==0) ){die "Zero interval not allowed!\n"}
if ( ! open(CF,$ARGV[0]) ) { die "Can't read file: ".$ARGV[0]."\n" }

# Read and store potentially meaningful records, then close the file
my %Types=('FreeDNS',1,'Dynu',1,'DuckDNS',1,'ChangeIP',1,'DNSExit',1);
my (%Type,%Pass,%OldAddr,%ChangeTime);
while (<CF>) {
  s/\s+$//;                      # Strip trailing white-space;
  my ($t,$h,$p,$junk) = split;   # separate, check and store fields.
  if ( defined($p) && defined($Types{$t}) && (! defined($junk) ) ) {
    $Type{$h}=$t; $Pass{$h}=$p; $OldAddr{$h}="x"; $ChangeTime{$h}=time()
  }
  elsif ( defined($t) && ($t !~ /^\043/) ) {
    die "Illegal record in file: ".$ARGV[0]."\n>> $_\n"
  }
}
close(CF);
if ( keys(%Pass) < 1 ) { die "No valid records found in file: ".$ARGV[0]."\n" }

# Sources for current IP address; use these in sequence
my @MyIps=("ifconfig.co/ip","ip1.dynupdate.no-ip.com","now-dns.com/ip",
   "dynamic.zoneedit.com/checkip.html","cpanel.com/showip.shtml","v4.ident.me");
if ( defined($opts{'x'}) ) {
@MyIps=("ipv6.icanhazip.com","ydns.io/api/v1/ip",
  "my.ip.fi","now-dns.com/ip","ipv6.duia.ro","v6.ident.me");
  $Mode="IPv6"
}

my $r=int(rand($#MyIps+1));      # Random-select first source
for (my $i=0;$i<$r;$i++) { push(@MyIps,shift(@MyIps)) }

# Process 'd' option
if ( defined($opts{'d'}) ) { fork and exit }

# Open log, then loop once if repeat-interval not set, else loop forever
openlog("dnspixie",,"local7");
while (1) {
  # Select next IP-address source, get current address
  push(@MyIps,my $MyIp=shift(@MyIps));
  my ($Status,$Address)=(0,);
  if ( ($Address=getMyAddress($MyIp,"$Mode")) ) {
    if ( defined($opts{'v'}) ) {syslog("info","$Mode Got $Address from $MyIp")}
    # Update each host address on first pass or if current address changed;
    # also force update if this program hasn't changed it during last 3 days.
    foreach my $h ( sort(keys(%Pass)) ) {
      if ( ($Address ne $OldAddr{$h}) || (time()-$ChangeTime{$h}>3*24*3600) ) {
        syslog("info","$Mode Attempting update for $h to: $Address");
        my ($p,$Response,$Good)=($Pass{$h},,);
        if ( $Type{$h} eq "FreeDNS" ) {
          my $x=defined($opts{'x'}) ? "&address=$Address" : "";
          if ( defined($Response=get(
              "https://freedns.afraid.org/dynamic/update.php?$p$x"))  )  {
            if ( ($Response=~m/has not chang/) || ($Response=~m/^Updated/) )  {
              $Response=~s/^ERROR: //; $Good="Y"
            }
          }
        }
        if ( $Type{$h} eq "DuckDNS" ) {
          my $x=defined($opts{'x'}) ? "&ipv6=$Address" : "&ip=$Address";
          if ( defined($Response=get(
              "https://www.duckdns.org/update?domains=$h&token=$p$x" .
                                                     "&verbose=true")) )      {
            if ( $Response=~m/^OK/ ) { $Good="Y" }
          }
        }
        if ( $Type{$h} eq "DNSExit" ) {
          if ( defined($Response=get(
              "https://api.dnsexit.com/dns/ud/?apikey=$p&host=$h")) )      {
            if ( $Response=~m/:[0-1],/ ) { $Good="Y" }
            my ($n1,$n2)=( index($Response,"{"), index($Response,"}") );
            $Response=substr($Response,$n1,$n2-$n1+1)
          }
        }
        if ( $Type{$h} eq "ChangeIP") {
          my ($x,$y)=split /:/,$p;
          if ( defined($Response=get(
              "https://nic.ChangeIP.com/nic/update?u=$x&p=$y&hostname=$h")) ) {
            if ( $Response=~m/200 Successful Update/ ) { $Good="Y" }
          }
        }
        if ( $Type{$h} eq "Dynu" )    {
          my ($x,$y)=split /:/,$p;
          my $m=defined($opts{'x'}) ? "myipv6=$Address" : "myip=$Address";
          if ( defined($Response=get(
              "https://api.dynu.com/nic/update?hostname=$h&$m&".
              "username=$x&password=$y"))  )  {
            if ( ($Response=~m/^good/) || ($Response=~m/^nochg/) ) { $Good="Y"}
          }
        }
        if ( defined($Good) ) {
          $OldAddr{$h}=$Address; $ChangeTime{$h}=time();
          syslog("info","$Mode ==> $Response")
        }
        else { $Status=1; syslog("info","$Mode Update failed!") }
      }
    }
  }
  else {
    syslog("info","$Mode Can't get current address from: $MyIp"); $Status=1;
  }
  if ( defined($opts{'m'}) ) {
    if ( defined($opts{'l'}) && ($Status==0) &&
         blink($opts{'l'},60*$opts{'m'}) ) {} else {sleep(60*$opts{'m'})}
  }
  else                       { exit($Status)      }
}

sub get {                        # Usage: get($url[,$Mode]);
  my ($handle,$string,$m);       # if $Mode is supplied, only use that mode.
  open($handle,'>',\$string);
  my $curl=new WWW::Curl::Easy;
  if ( defined($_[1])) {
    if($_[1] eq "IPv4") {$m=1} else {$m=2}; $curl->setopt(CURLOPT_IPRESOLVE,$m)
  }
  $curl->setopt(CURLOPT_TIMEOUT,30);
  $curl->setopt(CURLOPT_FOLLOWLOCATION,1);
  $curl->setopt(CURLOPT_WRITEDATA,$handle);
  $curl->setopt(CURLOPT_URL,shift);
  $curl->perform();
  return($string)
}
  
sub getMyAddress {               # Usage: getMyAddress($MyIp,$Mode)
  if ( defined(my $Address=get($_[0],$_[1])) ) {
    $Address=~s/^\s+|\s+$//g;    # Trim white-space and ipv4/6 tags
    $Address=~s/^\<ipv(4|6)\>//; $Address=~s/\<\/ipv(4|6)\>$//;
    if ( $_[1] eq "IPv6" ) {
      if ( ip_is_ipv6($Address) ) { return(ip_compress_address($Address,6)) }
    }                            # Return consistent forms of addresses
    else {
      if ( ip_is_ipv4($Address) ) { return(ip_expand_address  ($Address,4)) }
    }
  }
  return(0)
}

sub blink {                      # Usage: blink(led,secs);
  my $led=shift; my $secs=shift; # blinks 'led' for 'secs' seconds.
  if ( open(TRIGGER,">/sys/class/leds/$led/trigger") ) {
    print TRIGGER "none\n"; close(TRIGGER);
    my $bright=1;                # Set trigger mode, determine max brightness
    if ( open(MAX,"</sys/class/leds/$led/max_brightness") ) {
      while (<MAX>) { chomp; if ( $_=~/^\d+$/) {$bright=$_} }
      close(MAX)
    }
    for (my $k=0;$k<$secs;$k++) {# Alternate brightness value each pass
      open(BRIGHT, ">/sys/class/leds/$led/brightness");
      print BRIGHT ($k%2)*$bright,"\n"; close(BRIGHT); sleep(1)
    }
    return(1)
  }
  return(0)
}

__END__

=head1 NAME

DnsPixie - update client for popular dynamic DNS services

=head1 README

DnsPixie will update one or more designated DNS records at a number of
popular dynamic DNS services either once or periodically.

=head1 DESCRIPTION

C<DnsPixie> is a simple update client for the FreeDNS, Dynu, DuckDNS, DNSExit
and ChangeIP DNS services. It will attempt an immediate update when called,
then optionally loop at designated intervals, attempting
further updates when necessary.

=head1 USAGE

=over 2

DnsPixie [-x] [-d] [-v] [-m repeat-interval] [-l led] config-file

=back

e.g.: DnsPixie -m 15 /usr/local/etc/DnsPixie.conf

The repeat-interval value (where present) must be
expressed as a positive integer number of minutes.

If the '-d' option is used, DnsPixie will daemonize itself on startup.

If the '-x' option is included, DnsPixie will update IPv6
addresses instead of IPv4 addresses.

The '-v' option can be used to provide a more verbose log of activity.

The config-file value must be the name of a configuration
file which contains one or more 'service hostname password' records
where each password relates to the owner of the
corresponding hostname. Each record should appear on a
separate line as follows.

  FreeDNS  daisy.moo.com      ABC7PqRsTUvwXYEjLLM2R7ST8uvWX92AA3BbCz
  Dynu     mickey.dynu.net    myuser:mypassword
  DuckDNS  donald.duckdns.org 064a0540-864c-4f0f-8bf5-23857452b0c1
  ChangeIP huwey.changeip.net myuser:mypassword
  DNSExit  dewey.linkpc.net   5AD3eABC4r91PQR1234ZPyy5lllRSz
   .. etc.

Lines which begin in "#" will be ignored.
It is suggested that the configuration file should be
readable only by the intended program-user.
You may want to run separate
IPv4 and IPv6 instances of DnsPixie, using different configuration
files. Some services will allow hostnames to have either IPv4 or IPv6
records; others (e.g. Dynu, DuckDNS) will allow hostnames to have both.

I<Sys::Syslog> will be used for logging;
you might want to check your system's syslog configuration so
that you can view log messages. Additional detail will be logged
if the '-v' option is used.

To start DnsPixie on a Linux or BSD machine, you can insert into
your I<rc.local> file something like the following:

  /usr/local/sbin/DnsPixie.pl -dvm 10 /usr/local/etc/DnsPixie.conf

If your nameserver(s) are likely to change or become available
after DnsPixie starts, you should ensure that Dnsmasq (or something
similar) has been installed so that DnsPixie can always access
a name service.

On a Windows machine, you can start DnsPixie as a scheduled task.

On Linux machines which have a system LED, you can include the '-l'
option with a name like 'led0' (for the Raspberry Pi); DnsPixie will
then attempt to blink the designated led during successful execution.
You should not specify the same LED device for separate
instances of DnsPixie.

=head1 SCRIPT CATEGORIES

Networking
UNIX/System_administration

=head1 AUTHOR

Graham Jenkins <grahjenk@cpan.org>

=head1 COPYRIGHT

Copyright (c) 2014 Graham Jenkins. All rights reserved.
This program is free software; you can redistribute it
and/or modify it under the same terms as Perl itself.

=cut