#!/usr/bin/env python3

# Mathieu Turcotte, April 2011

# The easiest way to register this script on startup is to
# add to /etc/rc.local: ipupdate.py -d /etc/ipupdate.conf

import os, sys, re
import time, random
import configparser
import collections
import subprocess
import optparse
import signal

import logging
import logging.handlers

import urllib.error
import urllib.parse
import urllib.request

# The init process will send a SIGTERM signal at shutdown.
def sigterm_handler(signum, frame):
    logging.getLogger('ipupdate').info("SIGTERM received, shutting down.")
    logging.shutdown()
    exit(0)

# This is a simple wrapper around iputils ping.
# It's used to determine if we're connected to the
# internet before trying to update the external
# ip address.
def ping(host):
    fd = os.open(os.devnull, os.O_RDWR)
    try:
        ping_cmd = ["ping", "-q", "-c1", host]
        ping_ret = subprocess.call(ping_cmd, stdout=fd, stderr=fd, stdin=fd)
        return ping_ret == 0;
    finally:
        os.close(fd)

# This class is a simple wrapper around a REST
# external ip lookup service. It can be configured
# to enforce a minimal delay between each query.
class ExternalIpLookupService:

    # Over-simplified regular expression to match ip address.
    ip_regex = re.compile(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")

    def __init__(self, name, url, min_delay_between_query = 0):
        self.name = name
        self.url = url
        self.min_delay_between_query = min_delay_between_query
        self.last_query_time = 0
        self.logger = logging.getLogger("ipupdate.ExternalIpLookupService")

    def ready(self):
        # Check if the elapsed time since the last query is
        # greater than the maximal query rate for this service.
        delay_since_last_query = int(time.time()) - self.last_query_time
        return self.min_delay_between_query < delay_since_last_query

    def query(self):
        if not self.ready():
            return None

        self.logger.debug("Querying %s." % self.name)
        self.last_query_time = int(time.time())

        try:
            response = urllib.request.urlopen(self.url, None, 30)
            content = response.read().decode('utf-8')
            ip = self.ip_regex.search(content)
            if ip is not None:
                ip = ip.group(0)
                self.logger.debug("%s." % ip)
                return ip
        except urllib.error.HTTPError as err:
            error = "HTTPError (%s) querying %s." % (err.code, self.name)
            self.logger.error(error)
            return None
        except urllib.error.URLError as err:
            error = "URLError (%s) querying %s." % (err.reason, self.name)
            self.logger.error(error)
            return None

        self.logger.warning("No IP address in %s response." % self.name)
        return None

# The ExternalIpLookupServicePool holds many
# ExternalIpLookupService instances. This allow
# the IpWatchDogDaemon to do more queries while,
# hopefully, increasing the overall success rate.
class ExternalIpLookupServicePool:

    def __init__(self, services):
        self.services = services
        self.logger = logging.getLogger("ipupdate.ExternalIpLookupServicePool")

    def add(self, service):
        self.services.append(service)

    # Returns true if at least one external
    # ip lookup service is ready to handle
    # a request.
    def ready(self):
        for service in self.services:
            if service.ready():
                return True

        return False

    def query(self):
        # Shuffle the services list so we don't
        # call them in the same order every time.
        random.shuffle(self.services)

        for service in self.services:
            ip = service.query()
            if ip is not None:
                return ip

        self.logger.warning("Cant' determine the external IP address.")
        return None

class DNSUpdateService:

    # API datails can be found at https://www.dnsomatic.com/wiki/api

    realm = "DNSOMATIC"
    base_url = "https://updates.dnsomatic.com"
    update_url = "https://updates.dnsomatic.com/nic/update"
    messages = {
        "good": "The update was scheduled successfully.",
        "nochg": "No change.",
        "badauth": "The DNS-O-Matic username or password specified are incorrect.",
        "notfqdn": "The hostname specified is not a fully-qualified domain name.",
        "nohost": "The hostname passed could not be matched to any services configured.",
        "numhost": "You may update up to 20 hosts.",
        "abuse": "The hostname is blocked for update abuse.",
        "badagent": "The user-agent is blocked.",
        "dnserr": "DNS error encountered. Stop updating for 30 minutes.",
        "911": "There is a problem or scheduled maintenance on DNS-O-Matic."
    }

    def __init__(self, username, password, dry):

        self.dry = dry

        pswd_manager = urllib.request.HTTPPasswordMgr()
        pswd_manager.add_password(user = username,
                                  passwd = password,
                                  realm = self.realm,
                                  uri = self.base_url)

        auth_handler = urllib.request.HTTPBasicAuthHandler(pswd_manager)

        # Call self.opener instead of urlopen in order
        # to use the previously configured auth handler.
        self.opener = urllib.request.build_opener(auth_handler)

        self.logger = logging.getLogger("ipupdate.DNSUpdateService")

    # This method should return true on success,
    # false otherwise, and swallow exceptions.
    def update(self, ip):
        self.logger.info("Updating external IP address to %s" % ip)

        if self.dry:
            return True

        try:
            return_code = None
            params = urllib.parse.urlencode({"myip": ip})
            response = self.opener.open("%s?%s" % (self.update_url, params))
            response = response.read().decode("utf-8")
            return_code = response.split()[0]
        # Handlers raise an URLError when they run into a
        # problem. HTTPError is a subclass of URLError. It's
        # useful for dealing with HTTP errors.
        except urllib.error.HTTPError as err:
            err_msg = "HTTPError (%s)." % err.code
            self.logger.error(err_msg)
        except urllib.error.URLError as err:
            err_msg = "URLError (%s)." % err.reason
            self.logger.error(err_msg)

        if return_code in self.messages:
            self.logger.info(self.messages[return_code])

        # If DNS-O-Matic returned either good or nochg, we
        # can consider the update operation as successful.
        # Otherwise, we'll return false and the caller will
        # have the responsability of retrying the update at
        # a later time. 5 minutes seems to be a reasonable delay.
        return return_code == "good" or return_code == "nochg"

# The IpWatchDog periodically check the external ip address
# by querying an ExternalIpLookupService(Pool). When a change
# of the external IP address is detected, an update request
# is issued to its DNSUpdateService.
class IpWatchDog:
    PING_HOSTS = ['www.google.com', 'www.yahoo.com',
                  'www.facebook.com', 'www.bing.com',
                  'www.youtube.com', 'www.stackoverflow.com']

    def __init__(self, ip_lookup_service, dns_update_service, check_interval):
        self.logger = logging.getLogger("ipupdate.IpWatchDog")
        self.ip_lookup_service = ip_lookup_service
        self.dns_update_service = dns_update_service
        self.check_interval = check_interval
        self.current_ip = None

    def watch(self):
        while True:
            try: self.update()
            except Exception:
                self.logger.exception("Unhandled exception during update.")

            time.sleep(self.check_interval)

    def update(self):
        if not self.connected():
            self.logger.warning("No connection, skipping update.")
            return

        ip = self.ip_lookup_service.query()
        if ip is not None and ip != self.current_ip:
            if self.dns_update_service.update(ip):
                # Since the update was successful, we update
                # the current_ip. Otherwise, we simply do
                # nothing and it should still be unequal to
                # the new ip address on the next iteration.
                self.current_ip = ip

    def connected(self):
        random.shuffle(self.PING_HOSTS)
        for host in self.PING_HOSTS:
            if ping(host):
                return True
        return False

def daemonize():
    try:
        if os.fork() != 0:
            os._exit(0)

        os.setsid()

        if os.fork() != 0:
            os._exit(0)

        os.chdir("/")
        os.umask(666)

        # Redirect stdin, stdout and stderr.
        fd = os.open(os.devnull, os.O_RDWR)
        os.dup2(fd, sys.stdin.fileno())
        os.dup2(fd, sys.stdout.fileno())
        os.dup2(fd, sys.stderr.fileno())
        os.close(fd)

    except OSError as err:
        error_msg = "OSError(%s, %s)." % (err.strerror, err.errno)
        logging.getLogger('ipupdate').error(error_msg)
        logging.shutdown()
        exit(1)

def configure_logging(options):
    msgfmt = "%(asctime)s (%(levelname)s) %(name)s: %(message)s"
    datefmt = "%Y-%m-%d %H:%M:%S"
    formatter = logging.Formatter(fmt=msgfmt, datefmt=datefmt)

    RotatingFileHandler = logging.handlers.RotatingFileHandler
    rotating_file_handler = RotatingFileHandler(options.log_file,
                                                maxBytes=options.log_size,
                                                backupCount=options.log_num)
    rotating_file_handler.setFormatter(formatter)

    levels = {
        'debug' : logging.DEBUG,
        'info' : logging.INFO,
        'warning' : logging.WARNING,
        'error' : logging.ERROR,
        'critical' : logging.CRITICAL
    }

    logger = logging.getLogger('ipupdate')
    logger.setLevel(levels[options.log_level])
    logger.addHandler(rotating_file_handler)

def configure_signals():
    signal.signal(signal.SIGTERM, sigterm_handler)

def parse_args():
    usage = "usage: %prog [options] CONFIG_FILE"
    argparser = optparse.OptionParser(usage=usage)
    argparser.add_option('-d', '--daemon',
                        action="store_true",
                        default=False,
                        dest="daemonize",
                        help="run as daemon")
    argparser.add_option('--dry',
                        action="store_true",
                        default=False,
                        dest="dry",
                        help="dry run, doesn't send ip update")
    argparser.add_option('-l',
                        action="store",
                        choices=['debug', 'info', 'warning',
                                 'error', 'critical'],
                        default='info',
                        dest="log_level",
                        help="log level (debug, info, warning, error, critical)")

    # options contains all the optional arguments
    # args contains all the positional arguments
    (options, args) = argparser.parse_args()

    if len(args) != 1:
        argparser.error('no configuration file')

    options.config_filename = args[0]

    if not os.path.isfile(options.config_filename):
        argparser.error("configuration file doesn't exist")

    return options

def parse_config(options):
    config = configparser.SafeConfigParser({
        'check_interval': 60 * 5,
        'log_file': 'ipupdate.log',
        'log_size': 1024 * 128,
        'log_num': 5
    })

    try:
        config.read(options.config_filename)
        options.log_file = config.get('logging', 'log_file')
        options.log_size = config.getint('logging', 'log_size')
        options.log_num = config.getint('logging', 'log_num')
        options.username = config.get('identification', 'username')
        options.password = config.get('identification', 'password')
        options.check_interval = config.getint('configuration', 'check_interval')
    except configparser.Error as err:
        # We don't have any logging yet.
        print(err, file=sys.stderr)
        exit(1)

    return options

if __name__ == "__main__":

    options = parse_args()
    parse_config(options)

    configure_logging(options)

    if options.daemonize:
        configure_signals()
        daemonize()

    dns_update_service = DNSUpdateService(options.username, options.password, options.dry)
    ip_lookup_service_pool = ExternalIpLookupServicePool([
        ExternalIpLookupService("whatismyip", "http://www.whatismyip.com/automation/n09230945.asp", 300),
        ExternalIpLookupService("dyndns", "http://checkip.dyndns.org/", 300),
        ExternalIpLookupService("ifconfig", "http://ifconfig.me/ip", 300),
        ExternalIpLookupService("codebrainz", "http://showip.codebrainz.ca/", 300),
        ExternalIpLookupService("icanhazip", "http://icanhazip.com/", 300),
        ExternalIpLookupService("externalip", "http://api.externalip.net/ip/", 300),
        ExternalIpLookupService("dnsomatic", "http://myip.dnsomatic.com/", 300)
    ])

    logging.getLogger('ipupdate').info("Started.")

    watchdog = IpWatchDog(ip_lookup_service_pool,
                          dns_update_service,
                          options.check_interval)
    watchdog.watch()