#!/usr/bin/python3

#
# Yet Another DDNS updater
#
# SPDX-License-Identifier: Apache-2.0
#
# Copyright(c) 2025 Gary Buhrmaster <gary.buhrmaster@gmail.com>
#

import socket
import argparse
import sys
import urllib.request
import urllib.error
import urllib.parse
import random
import ipaddress
import json
import os
import shlex

# Not everything is available in Python
if not hasattr(socket, "IPV6_ADDR_PREFERENCES"):
    socket.IPV6_ADDR_PREFERENCES = 72
if not hasattr(socket, "IPV6_PREFER_SRC_TMP"):
    socket.IPV6_PREFER_SRC_TMP = 1
if not hasattr(socket, "IPV6_PREFER_SRC_PUBLIC"):
    socket.IPV6_PREFER_SRC_PUBLIC = 2

#
# Wrappers to force urlopen to use specified transport
#
def urlopenIPv6(*pargs, **kwargs):
    original_socket_getaddrinfo = socket.getaddrinfo
    def getaddrinfoIPv6(host, port, family=0, type=0, proto=0, flags=0):
        # pylint: disable=W0622 disable=W0613
        return original_socket_getaddrinfo(host, port, family=socket.AF_INET6, type=type, proto=proto, flags=flags)
    socket.getaddrinfo = getaddrinfoIPv6
    r = None
    try:
        r = urllib.request.urlopen(*pargs, **kwargs)
    finally:
        socket.getaddrinfo = original_socket_getaddrinfo
    return r

def urlopenIPv4(*pargs, **kwargs):
    original_socket_getaddrinfo = socket.getaddrinfo
    def getaddrinfoIPv4(host, port, family=0, type=0, proto=0, flags=0):
        # pylint: disable=W0622 disable=W0613
        return original_socket_getaddrinfo(host, port, family=socket.AF_INET, type=type, proto=proto, flags=flags)
    socket.getaddrinfo = getaddrinfoIPv4
    r = None
    try:
        r = urllib.request.urlopen(*pargs, **kwargs)
    finally:
        socket.getaddrinfo = original_socket_getaddrinfo
    return r

def urlopenANY(*pargs, **kwargs):
    return urllib.request.urlopen(*pargs, **kwargs)

#
# Obtain the IP address for the hostname via DNS
#
def getDNSIPv4(hostname, verbose=False, debug=False):
    # pylint: disable=W0613
    try:
        ipaddr = ipaddress.ip_address(socket.getaddrinfo(hostname, None, socket.AF_INET)[0][4][0])
        if ipaddr.version == 4:
            if debug:
                print('DNS IPv4 address is {}'.format(ipaddr.compressed))
            return ipaddr.compressed
        if debug:
            print('DNS IPv4 address was not valid')
        return None
    except (OSError, ValueError, TypeError, IndexError):
        if debug:
            print('DNS IPv4 could not be determined')
        return None

def getDNSIPv6(hostname, verbose=False, debug=False):
    # pylint: disable=W0613
    try:
        ipaddr = ipaddress.ip_address(socket.getaddrinfo(hostname, None, socket.AF_INET6)[0][4][0])
        if ipaddr.version == 6:
            if debug:
                print('DNS IPv6 address is {}'.format(ipaddr.compressed))
            return ipaddr.compressed
        if debug:
            print('DNS IPv6 address was not valid')
        return None
    except (OSError, ValueError, TypeError, IndexError):
        if debug:
            print('DNS IPv6 could not be determined')
        return None

#
# Determine local ip address
#
def getLocalIPv4(verbose=False, debug=False):
    # pylint: disable=W0613
    if debug:
        print('determining local IPv4 address by opening a local socket')
    ip = None
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        # doesn't even have to be reachable
        # so we use the documentation IPv4
        s.connect(('192.0.2.1', 1))
        ip = s.getsockname()[0]
        try:
            if ipaddress.ip_address(ip).version != 4:
                if debug:
                    print('  returned ip adddress {} is not a valid IPv4 address'.format(ip))
                    ip = None
            else:
                ip = ipaddress.ip_address(ip).compressed
        except ValueError:
            if debug:
                print('  returned ip address {} is not a valid IP address'.format(ip))
                ip = None
    except (OSError, ValueError, TypeError, IndexError):
        if debug:
            print('  exception on socket operation')
        ip = None
    finally:
        s.close()
    if debug:
        print('  determined local IPv4 address to be {}'.format(ip))
    return ip

def getLocalIPv6(verbose=False, debug=False):
    # pylint: disable=W0613
    if debug:
        print('determining local IPv6 address by opening a local socket')
    ip = None
    try:
        s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)

        if sys.platform.startswith('linux'):
            # try to request the global ipv6 address
            try:
                s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_ADDR_PREFERENCES, socket.IPV6_PREFER_SRC_PUBLIC)
            except OSError:
                if debug:
                    print('  exception trying to set socket option')

        # doesn't even have to be reachable
        # so we use the documentation IPv6
        s.connect(('2001:DB8::1', 1))
        ip = s.getsockname()[0]
        try:
            if ipaddress.ip_address(ip).version != 6:
                if debug:
                    print('  returned ip adddress {} is not a valid IPv6 address'.format(ip))
                    ip = None
            else:
                ip = ipaddress.ip_address(ip).compressed
        except ValueError:
            if debug:
                print('  returned ip address {} is not a valid IP address'.format(ip))
                ip = None
    except (OSError, ValueError, TypeError, IndexError):
        if debug:
            print('  exception on socket operation')
        ip = None
    finally:
        s.close()
    if debug:
        print('  determined local IPv6 address to be {}'.format(ip))
    return ip

#
# Determine external ip address from external sources
#
def getRemoteExternalIPv4(verbose=False, debug=False):
    # pylint: disable=W0613
    externalProviders = [
                          'https://ipv4.nsupdate.info/myip',
                          'https://api.ipify.org',
                          'https://ifconfig.me/ip',
                          'https://ifconfig.co/ip',
                          'https://ipinfo.io/ip',
                          'https://ipv4.icanhazip.com'
                        ]
    random.shuffle(externalProviders)
    for provider in externalProviders:
        if debug:
            print('trying to determine external IPv4 address from {}'.format(provider))
        try:
            request = urllib.request.Request(provider, method='GET')
            request.add_header = ('User-Agent', 'Python-urllib/3')
            response = urlopenIPv4(request, timeout=20)
            if (response.status < 200) or (response.status > 299):
                if debug:
                    print('  failed to get response')
                continue
            ip = response.read().decode("utf-8").strip()
            if ip:
                try:
                    if ipaddress.ip_address(ip).version != 4:
                        if debug:
                            print('  returned ip adddress {} is not a valid IPv4 address'.format(ip))
                        continue
                except ValueError:
                    if debug:
                        print('  returned ip address {} is not a valid IP address'.format(ip))
                    continue
                if debug:
                    print('  determined ip address is {}'.format(ip))
                return ip
            if debug:
                print('  no ip address returned')
        except (IOError, ValueError, OSError, urllib.error.URLError, urllib.error.HTTPError):
            if debug:
                print('  exception occurred')
    return None

def getRemoteExternalIPv6(verbose=False, debug=False):
    # pylint: disable=W0613
    externalProviders = [
                          'https://ipv6.nsupdate.info/myip',
                          'https://api6.ipify.org',
                          'https://ifconfig.me/ip',
                          'https://ifconfig.co/ip',
                          'https://v6.ipinfo.io/ip',
                          'https://ipv6.icanhazip.com'
                        ]
    random.shuffle(externalProviders)
    for provider in externalProviders:
        if debug:
            print('trying to determine external IPv6 address from {}'.format(provider))
        try:
            request = urllib.request.Request(provider, method='GET')
            request.add_header = ('User-Agent', 'Python-urllib/3')
            response = urlopenIPv6(request, timeout=20)
            if (response.status < 200) or (response.status > 299):
                if debug:
                    print('  failed to get response')
                continue
            ip = response.read().decode("utf-8").strip()
            if ip:
                try:
                    if ipaddress.ip_address(ip).version != 6:
                        if debug:
                            print('  returned ip adddress {} is not a valid IPv6 address'.format(ip))
                        continue
                except ValueError:
                    if debug:
                        print('  returned ip address {} is not a valid IP address'.format(ip))
                    continue
                if debug:
                    print('  determined ip address is {}'.format(ip))
                return ip
            if debug:
                print('  no ip address returned')
        except (IOError, ValueError, OSError, urllib.error.URLError, urllib.error.HTTPError):
            if debug:
                print('  exception occurred')
    return None

#
# The "defacto" standard ddns update method (dyn was an early popular ddns
# provider, and many/most providers have followed their update method)
#
def dynUpdate(url=None, username=None, password=None, hostname=None, myip=None, transport='ANY', method='GET', verbose=False, debug=False, extradata={}):
    # pylint: disable=W0102

    if debug:
        print('dynUpdate called with:')
        print('  url:          {}'.format(url))
        print('  username:     {}'.format(username))
        print('  password:     {}'.format(password))
        print('  hostname:     {}'.format(hostname))
        print('  myip:         {}'.format(myip))
        print('  transport:    {}'.format(transport))
        print('  method:       {}'.format(method))
        print('  extradata:    {}'.format(extradata))

    if url is None:
        print('The dynamic ddns update URL was not specified')
        return 1
    if username is None:
        print('The dynamic ddns username was not specified')
        return 1
    if password is None:
        print('The dynamic ddns password was not specified')
        return 1
    if hostname is None:
        print('The dynamic ddns hostname was not specified')
        return 1
    if method not in ('GET', 'POST'):
        print('The dynamic ddns method is not GET or POST')
        return 1

    querydata = {'hostname': hostname}
    if myip is not None:
        querydata['myip'] = myip
    querydata.update(extradata)

    data = urllib.parse.urlencode(querydata)
    headers = {'User-Agent': 'Python-urllib/3'}
    if method == 'GET':
        request = urllib.request.Request(url + '?' + data, headers=headers, method='GET')
    else:
        request = urllib.request.Request(url, data=data.encode('utf-8'), headers=headers, method='POST')

    # Install opener to support basic authentication
    passwordManager = urllib.request.HTTPPasswordMgrWithDefaultRealm()
    passwordManager.add_password(None, url, username, password)
    authHandler = urllib.request.HTTPBasicAuthHandler(passwordManager)
    opener = urllib.request.build_opener(authHandler)
    original_openerDirector = urllib.request.OpenerDirector
    urllib.request.install_opener(opener)

    # Use the required transport for the open
    try:
        if transport == 'IPv4':
            response = urlopenIPv4(request)
        elif transport == 'IPv6':
            response = urlopenIPv6(request)
        else:
            response = urlopenANY(request)
    except (IOError, ValueError, OSError, urllib.error.URLError, urllib.error.HTTPError) as e:
        print('exception occured when attempting update')
        if debug:
            print(e)
        return 1
    finally:
        urllib.request.OpenerDirector = original_openerDirector

    # Handle response
    if (response.status < 200) or (response.status > 299):
        print('Update failed with response status {}'.format(response.status))
        return 1

    txt = response.read().decode('utf-8')
    if txt.startswith(('good ')):
        if verbose or debug:
            print('Update successful to IP address {}'.format(myip))
    elif txt.startswith(('nochg ')):
        if verbose or debug:
            print('No update required to set IP address {}'.format(myip))
    else:
        print('Failed to set host {} IP address to {}'.format(hostname, myip))
        print(txt)
        return 1

    return 0

#
# cloudflare dns update
#
def cloudflareUpdate(username=None, password=None, hostname=None, myip=None, verbose=False, debug=False, extradata={}):
    # pylint: disable=W0102 disable=W0613

    if debug:
        print('cloudflareUpdate called with:')
        print('  username:     {}'.format(username))
        print('  password:     {}'.format(password))
        print('  hostname:     {}'.format(hostname))
        print('  myip:         {}'.format(myip))
        print('  extradata:    {}'.format(extradata))

    if username is None:
        print('The dynamic ddns username was not specified')
        return 1
    if password is None:
        print('The dynamic ddns password was not specified')
        return 1
    if hostname is None:
        print('The dynamic ddns hostname was not specified')
        return 1
    if myip is None:
        print('The dynamic ddns ip address was not specified')
        return 1
    try:
        ipaddr = ipaddress.ip_address(myip)
    except ValueError:
        print('Specified IP address: {} is invalid'.format(myip))
        return 1
    if ipaddr.version == 6:
        updateType = 'AAAA'
    elif ipaddr.version == 4:
        updateType = 'A'
    else:
        print('The ip address' + myip + ' is not a valid IPv4 or IPv6 address')
        return 1

    url = 'https://api.cloudflare.com/client/v4'

    headers = {'User-Agent': 'Python-urllib/3',
               'Content-Type': 'application/json',
               'Accept': 'application/json',
               'Authorization': 'Bearer ' + password}

    # Find the zone id by working backwords from hostname
    zoneid = None
    fragments = hostname.split('.')
    print(len(fragments))
    for i in range(0, len(fragments)):
        zonename = '.'.join(fragments[i:])
        querydata = {'name': zonename}
        if debug:
            print('Querying for zone ' + zonename)
        request = urllib.request.Request(url + '/zones?' + urllib.parse.urlencode(querydata), headers=headers, method='GET')
        try:
            response = urlopenANY(request)
        except (IOError, ValueError, OSError, urllib.error.URLError, urllib.error.HTTPError) as e:
            print('Exception occured when attempting to determine zone id')
            if debug:
                print(e)
            return 1
        # Handle response
        if (response.status < 200) or (response.status > 299):
            print('Error while trying to get zone id for domain {}, response status: {}'.format(zonename, response.status))
        txt = response.read().decode('utf-8')
        if debug:
            print(txt)
        try:
            cfreply = json.loads(txt)
        except json.JSONDecodeError:
            print('Error while decoding json response text for zone id for domain {}'.format(zonename))
            continue
        success = cfreply.get('success', False)
        if not success:
            print('The request to get the zone id was not successful for domain {}'.format(zonename))
            continue
        cfresults = cfreply.get('result', [])
        if len(cfresults) > 1:
            print('The request to get the zone id for domain {} returned multiple results'.format(zonename))
            continue
        if len(cfresults) == 0:
            print('The request to get the zone id for domain {} did not return a result'.format(zonename))
            continue
        zoneid = cfresults[0].get('id', None)
        if zoneid is not None:
            break
    if zoneid is None:
        print('The request to get the zone id did not return a value')
        return 1
    if debug:
        print('the zone id is: {}'.format(zoneid))

    # Find the dns record id
    querydata = {'match': 'all', 'name': hostname, 'type': updateType}
    request = urllib.request.Request(url + '/zones/' + zoneid + '/dns_records?' + urllib.parse.urlencode(querydata), headers=headers, method='GET')
    try:
        response = urlopenANY(request)
    except (IOError, ValueError, OSError, urllib.error.URLError, urllib.error.HTTPError) as e:
        print('Exception occured when attempting to determine record id')
        if debug:
            print(e)
        return 1
    # Handle response
    if (response.status < 200) or (response.status > 299):
        print('Error while trying to get record id, response status: {}'.format(response.status))
        return 1
    txt = response.read().decode('utf-8')
    if debug:
        print(txt)
    try:
        cfreply = json.loads(txt)
    except json.JSONDecodeError:
        print('Error while decoding json response text for record id')
        return 1
    success = cfreply.get('success', False)
    if not success:
        print('The request to get the recored id was not successful')
        return 1
    cfresults = cfreply.get('result', [])
    if len(cfresults) == 0:
        print('The request to get the record id did not return a result')
        return 1
    if len(cfresults) > 1:
        print('The request to get the record id returned multiple results')
        return 1
    recordid = cfresults[0].get('id', None)
    if recordid is None:
        print('The request to get the record id did not return a value')
        return 1
    if debug:
        print('the record id is: {}'.format(recordid))

    # Update the record
    patchdata = {'name': hostname, 'type': updateType, 'content': myip}
    patchdata.update(extradata)
    request = urllib.request.Request(url + '/zones/' + zoneid + '/dns_records/' + recordid, data=json.dumps(patchdata).encode('utf-8'), headers=headers, method='PATCH')
    try:
        response = urlopenANY(request)
    except (IOError, ValueError, OSError, urllib.error.URLError, urllib.error.HTTPError) as e:
        print('Exception occured when attempting to update ip address')
        if debug:
            print(e)
        return 1
    # Handle response
    if (response.status < 200) or (response.status > 299):
        print('Error while trying to update ip address: {}'.format(response.status))
        return 1
    txt = response.read().decode('utf-8')
    if debug:
        print(txt)
    try:
        cfreply = json.loads(txt)
    except json.JSONDecodeError:
        print('Error while decoding json response text for ip update request')
        return 1
    success = cfreply.get('success', False)
    if not success:
        print('The request to update the ip address was not successful')
        return 1

    return 0


if __name__ == '__main__':

    # Support use of environmental variables (prefered for at least the password)
    ddns_api = os.getenv('DDNS_API')
    if ddns_api is not None:
        if ddns_api not in ['dyn', 'he', 'cloudflare']:
            ddns_api = None
    ddns_hostname = os.getenv('DDNS_HOSTNAME', os.getenv('DDNS_FQDN'))
    ddns_username = os.getenv('DDNS_USERNAME', os.getenv('DDNS_CREDENTIALS', os.getenv('DDNS_API_KEY_ID')))
    ddns_password = os.getenv('DDNS_PASSWORD', os.getenv('DDNS_CREDENTIALS_PASSWORD', os.getenv('DDNS_API_KEY_SECRET')))
    ddns_args = os.getenv('DDNS_ARGS')
    ddns_argList = []
    if ddns_args is not None:
        ddns_argList = shlex.split(ddns_args)
    else:
        ddns_argList = []

    parser = argparse.ArgumentParser()
    parser.add_argument('--api',
                        action='store', type=str, required=ddns_api is None, default=ddns_api,
                        choices=['dyn', 'he', 'cloudflare'],
                        help='The API type')
    parser.add_argument('--dyn-api-url',
                        action='store', type=str, required=False, dest='apiurl',
                        help='The url to use if the api type is dyn')
    parser.add_argument('--hostname', '--fqdn',
                        action='store', type=str, required=ddns_hostname is None, default=ddns_hostname,
                        help='The hostname to update')
    parser.add_argument('--username', '--credentials', '--api-key-id',
                        action='store', type=str, required=ddns_username is None, default=ddns_username,
                        help='The username for the update')
    parser.add_argument('--password', '--credentials-password', '--api-key-secret',
                        action='store', type=str, required=ddns_password is None, default=ddns_password,
                        help='The password/apikey for the update')
    ipgroup = parser.add_mutually_exclusive_group(required=False)
    ipgroup.add_argument('--use-ddns-provider-ip', '--use-ddns-provider-IP', '--use-ddns-ip', '--use-ddns-IP',
                        action='store_true', default=False, dest='providerip',
                        help='Use the external IP address as determined by the dns provider if supported')
    ipgroup.add_argument('--use-localip', '--use-localIP', '--use-local-ip', '--use-local-IP',
                        action='store_true', default=False, dest='localip',
                        help='Use the local determined IP rather than the default IP')
    parser.add_argument('--ipv4', '--IPv4', '--4',
                        action='store_true', default=False,
                        help='Update the IPv4 address')
    parser.add_argument('--ipv6', '--IPv6', '--6',
                        action='store_true', default=False,
                        help='Update the IPv6 address')
    parser.add_argument('--ipaddress', '--IPAddress', '--ip-address', '--IP-address',
                        type=str,
                        help='The IP address to set')
    parser.add_argument('--force',
                        action='store_true', default=False,
                        help='Force the update even when there appears to be no change')
    parser.add_argument('--verbose',
                        action='store_true', default=False,
                        help='Verbose logging')
    parser.add_argument('--debug',
                        action='store_true', default=False,
                        help='Debug logging')
    parser.add_argument('--quiet',
                        action='store_true', default=False,
                        help='Quiet logging')

    args = parser.parse_args(ddns_argList + sys.argv[1:])

    ReturnCode = 0

    UpdateIPv4 = 1
    UpdateIPv6 = 1
    UpdateIP = 0

    IPv4 = None
    IPv6 = None
    IP = None

    IPv4DNS = None
    IPv6DNS = None

    if args.ipv4:
        UpdateIPv4 += 1
        UpdateIPv6 -= 1

    if args.ipv6:
        UpdateIPv4 -= 1
        UpdateIPv6 += 1

    if args.ipaddress:
        UpdateIP = 1
        UpdateIPv4 = 0
        UpdateIPv6 = 0

    if UpdateIPv4 > 0:
        IPv4DNS = getDNSIPv4(args.hostname, verbose=args.verbose, debug=args.debug)
    else:
        IPv4DNS = None

    if UpdateIPv6 > 0:
        IPv6DNS = getDNSIPv6(args.hostname, verbose=args.verbose, debug=args.debug)
    else:
        IPv6DNS = None

    if UpdateIPv4:
        if (args.providerip) and (args.api in ('dyn', 'he')):
            if args.verbose or args.debug:
                print('IPv4 address will be determined by dynamic dns provider')
        elif args.localip:
            IPv4 = getLocalIPv4(verbose=args.verbose, debug=args.debug)
            if IPv4 is None:
                UpdateIPv4 = 0
                print('Unable to determine local IPv4 address')
                ReturnCode = 1
            else:
                if args.verbose or args.debug:
                    print('Local IPv4 address determined to be: {}'.format(IPv4))
                if IPv4 == IPv4DNS:
                    if args.force:
                        if args.verbose or args.debug:
                            print('Local IPv4 address is the same as DNS, but update will be forced')
                    else:
                        if args.verbose or args.debug:
                            print('Local IPv4 address is the same as DNS, no update will be performed')
                        UpdateIPv4 = 0
                else:
                    if args.verbose or args.debug:
                        print('Local IPv4 address is different than DNS, will be updated')
        else:
            IPv4 = getRemoteExternalIPv4(verbose=args.verbose, debug=args.debug)
            if IPv4 is None:
                UpdateIPv4 = 0
                print('Unable to determine external IPv4 address')
                ReturnCode = 1
            else:
                if args.verbose or args.debug:
                    print('External IPv4 address determined to be: {}'.format(IPv4))
                if IPv4 == IPv4DNS:
                    if args.force:
                        if args.verbose or args.debug:
                            print('External IPv4 address is the same as DNS, but update will be forced')
                    else:
                        if args.verbose or args.debug:
                            print('External IPv4 address is the same as DNS, no update will be performed')
                        UpdateIPv4 = 0
                else:
                    if args.verbose or args.debug:
                        print('External IPv4 address is different than DNS, will be updated')

    if UpdateIPv6:
        if (args.providerip) and (args.api in ('dyn', 'he')):
            if args.verbose or args.debug:
                print('IPv6 address will be determined by dynamic dns provider')
        elif args.localip:
            IPv6 = getLocalIPv6(verbose=args.verbose, debug=args.debug)
            if IPv6 is None:
                UpdateIPv6 = 0
                print('Unable to determine local IPv6 address')
                ReturnCode = 1
            else:
                if args.verbose or args.debug:
                    print('Local IPv6 address determined to be: {}'.format(IPv6))
                if IPv6 == IPv6DNS:
                    if args.force:
                        if args.verbose or args.debug:
                            print('Local IPv6 address is the same as DNS, but update will be forced')
                    else:
                        if args.verbose or args.debug:
                            print('Local IPv6 address is the same as DNS, no update will be performed')
                        UpdateIPv6 = 0
                else:
                    if args.verbose or args.debug:
                        print('Local IPv6 address is different than DNS, will be updated')
        else:
            IPv6 = getRemoteExternalIPv6(verbose=args.verbose, debug=args.debug)
            if IPv6 is None:
                UpdateIPv6 = 0
                print('Unable to determine remote IPv6 address')
                ReturnCode = 1
            else:
                if args.verbose or args.debug:
                    print('External IPv6 address determined to be: {}'.format(IPv6))
                if IPv6 == IPv6DNS:
                    if args.force:
                        if args.verbose or args.debug:
                            print('External IPv6 address is the same as DNS, but update will be forced')
                    else:
                        if args.verbose or args.debug:
                            print('External IPv6 address is the same as DNS, no update will be performed')
                        UpdateIPv6 = 0
                else:
                    if args.verbose or args.debug:
                        print('External IPv6 address is different than DNS, will be updated')

    if UpdateIP:
        try:
            IP = ipaddress.ip_address(args.ipaddress).compressed
        except ValueError:
            print('Specified IP address {} is invalid'.format(args.ipaddress))
            UpdateIP = 0
            IP = None
            ReturnCode = 1
        else:
            if args.verbose or args.debug:
                print('Specified IP address is: {} '.format(IP))
            if IP in (IPv6DNS, IPv4DNS):
                if args.force:
                    if args.verbose or args.debug:
                        print('Specified IP address is the same as DNS, but update will be forced')
                else:
                    if args.verbose or args.debug:
                        print('Specified IP address is the same as DNS, no update will be performed')
                    UpdateIP = 0
            else:
                if args.verbose or args.debug:
                    print('Specified IP address is different than DNS, will be updated')

    #
    # Scenerios that the API providers may support
    #
    # * (single) ip address specified
    # * IPv4 and/or IPv6 address derived (either via local or external)
    # * IPv4 and/or IPv6 address will be auto determined by provider
    #

    #
    # For the providers that support the dyn protocol
    # they share the majority of code paths.
    #
    # For the dyn protocol, the transport type only
    # matters if the address will be auto-determined.
    #
    # Most of the time the response status will be 200
    # with the actual results in the text (good, nochg,
    # badauth, interval)
    #
    # While POST is arguably the correct protocol type,
    # dyn itself has deprecated it, and the other
    # providers also support GET, so we use GET
    #

    if args.api in ('dyn', 'he'):

        if args.api == 'dyn':

            # https://{user}:{updater client key}@members.dyndns.org/v3/update?hostname={hostname}&myip={IP Address}

            updateURL = 'https://members.dyndns.org/v3/update'

            if args.apiurl:
                updateURL = args.apiurl

        elif args.api == 'he':

            # dynUpdate('https://dyn.dns.he.net/nic/update', username=args.hostname, password=args.password, hostname=args.hostname, myip=xxxx)

            updateURL = 'https://dyn.dns.he.net/nic/update'

        else:

            print('Internal error - unsupported API: {}'.format(args.api))
            ReturnCode = 1
            sys.exit(ReturnCode)

        rc = 0

        if UpdateIP:
            rc += dynUpdate(url=updateURL, username=args.username, password=args.password, hostname=args.hostname, myip=IP, verbose=args.verbose, debug=args.debug)

        if UpdateIPv4:
            if IPv4:
                rc += dynUpdate(url=updateURL, username=args.username, password=args.password, hostname=args.hostname, myip=IPv4, verbose=args.verbose, debug=args.debug)
            else:
                rc += dynUpdate(url=updateURL, username=args.username, password=args.password, hostname=args.hostname, transport='IPv4', verbose=args.verbose, debug=args.debug)

        if UpdateIPv6:
            if IPv6:
                rc += dynUpdate(url=updateURL, username=args.username, password=args.password, hostname=args.hostname, myip=IPv6, verbose=args.verbose, debug=args.debug)
            else:
                rc += dynUpdate(url=updateURL, username=args.username, password=args.password, hostname=args.hostname, transport='IPv6', verbose=args.verbose, debug=args.debug)

        if rc > 0:
            ReturnCode = 1

    elif args.api in ('cloudflare'):

        rc = 0

        if UpdateIP:
            rc += cloudflareUpdate(username=args.username, password=args.password, hostname=args.hostname, myip=IP, verbose=args.verbose, debug=args.debug)

        if UpdateIPv4:
            rc += cloudflareUpdate(username=args.username, password=args.password, hostname=args.hostname, myip=IPv4, verbose=args.verbose, debug=args.debug)

        if UpdateIPv6:
            rc += cloudflareUpdate(username=args.username, password=args.password, hostname=args.hostname, myip=IPv6, verbose=args.verbose, debug=args.debug)

        if rc > 0:
            ReturnCode = 1

    else:

        print('Unsupported API: {}'.format(args.api))
        ReturnCode = 1

    sys.exit(ReturnCode)
