diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..0e2e870 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length=100 diff --git a/.gitignore b/.gitignore index 894a44c..95fc14e 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ venv.bak/ # mypy .mypy_cache/ + +# Ignore personal configuration (contains secrets!) +config.yaml diff --git a/README.md b/README.md index 40736bd..ee54469 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ The script can be run manually by calling `godaddy-ddns` with the following opti - `--config`: Path to the configuration file (default: `/etc/godaddy-ddns/config.yaml`). - `--force`: Update the IP address regardless of the value in the cache. - `--quiet`: Don't print messages to `stdout`. +- `--nocolour`: Don't use colour when printing to `stdout`. ### Systemd diff --git a/bin/godaddy-ddns b/bin/godaddy-ddns index be81491..5864b88 100755 --- a/bin/godaddy-ddns +++ b/bin/godaddy-ddns @@ -1,6 +1,65 @@ #!/usr/bin/env python3 +import sys +import warnings + +import click + import godaddy_ddns -godaddy_ddns.update_ip() +@click.command() +@click.option( + '--config', '-c', + default="/etc/godaddy-ddns/config.yaml", + help="Path to configuration file (.yaml).", + type=click.File('r') +) +@click.option('--force', '-f', is_flag=True, help="Update the IP regardless of the cached IP.") +@click.option('--quiet', '-q', is_flag=True, help="Don't print to stdout.") +@click.option('--nocolour', is_flag=True, help="Don't colourise messages to stdout.") +def main(config, force, quiet, nocolour): + # Define an echo function to account for 'quiet' and 'nocolour' + def echo(msg): + if quiet: + pass + elif nocolour: + print(msg) + else: + godaddy_ddns.print_colourised(msg) + + # Notify if forced + if force: + echo("Info: Beginning forced update.") + + # Perform the update + try: + # Catch and record warnings to print later + with warnings.catch_warnings(record=True) as caught_warnings: + updated, myip, domains = godaddy_ddns.update_ip(config, force) + except (godaddy_ddns.ConfigError, + godaddy_ddns.BadResponse, + PermissionError, + ConnectionError) as e: + # Echo the message and exit with failure + echo(str(e)) + sys.exit(1) + except: + echo("Error: An unexpected exception occurred!") + raise # raise the exception for debugging/issue reporting + + # Print any warnings + for warning in caught_warnings: + echo(str(warning.message)) + + # Report the status of the update + if updated: + forced = "forcefully " if force else "" + for domain in domains: + echo("Success: IP address {}updated to {} for {}.".format(forced, myip, domain)) + else: + echo("Success: IP address is already up-to-date ({})".format(myip)) + + +if __name__ == "__main__": + main() diff --git a/godaddy_ddns/__init__.py b/godaddy_ddns/__init__.py index 7938913..b73ff03 100755 --- a/godaddy_ddns/__init__.py +++ b/godaddy_ddns/__init__.py @@ -1,8 +1,6 @@ import datetime -import errno import os -import re -import sys +import warnings import click import pif @@ -12,73 +10,75 @@ from godaddypy.client import BadResponse -@click.command() -@click.option( - '--config', - default="/etc/godaddy-ddns/config.yaml", - help="Path to configuration file (.yaml).", - type=click.File('r') -) -@click.option('--force', is_flag=True, help="Update the IP regardless of the cached IP.") -@click.option('--quiet', is_flag=True, help="Don't print to stdout.") -def update_ip(config, force, quiet): +class ConfigError(Exception): + pass + + +def update_ip(config_file, force): + """Update the IP address for the configured domains/subdomains + + Parameters: + - config_file: Open file or file-like object configuration file + - force: boolean flag for forcing updates (True => force update) + + Returns: + - updated: bool indicating whether the IP address was updated + - myip: str containing the current IP address + - domains: list of updated domains (eg. ["[sub1,sub2].[example.com]"]) + """ # Load the configuration file try: - conf = yaml.load(config) - except yaml.MarkedYAMLError as e: - # If the error is marked, give the details - print("Error: Malformed configuration file.") - print(e) - sys.exit(errno.EINVAL) - except yaml.YAMLError: - # Otherwise just state that it's malformed - print("Error: Malformed configuration file") - sys.exit(errno.EINVAL) + config = yaml.load(config_file) + except (yaml.MarkedYAMLError, yaml.YAMLError) as e: + raise ConfigError("Error: {}".format(e)) # Check the supplied log path - if conf.get("log_path", False): + log_path = config.get("log_path") + if log_path: # Make sure that the log path exists and is writable try: - touch(conf["log_path"]) + touch(log_path) except PermissionError: - print("Error: Insufficient permissions to write log to '{}'.".format(conf['log_path'])) - sys.exit(errno.EACCES) + msg = "Error: Insufficient permissions to write log to '{}'.".format(log_path) + raise PermissionError(msg) # Currently no log, so just raise an exception # Define the logging function def write_log(msg): now = datetime.datetime.now().isoformat(' ', timespec='seconds') - with open(conf["log_path"], 'a') as f: + with open(log_path, 'a') as f: f.write("[{now}]: {msg}\n".format(now=now, msg=msg)) - if not quiet: - click.echo(msg) else: # No log file specified, so disable logging def write_log(msg): - if not quiet: - click.echo(msg) # Just print the message, don't log it + pass # Check the supplied cache path - if conf.get("cache_path", False): + cache_path = config.get("cache_path") + if cache_path: # Make sure that the log path exists and is writable try: - touch(conf["cache_path"]) # Create the file if necessary + touch(cache_path) # Create the file if necessary except PermissionError: - write_log("Error: Insufficient permissions to write to cache ({}).".format(conf['cache_path'])) - sys.exit(errno.EACCES) + msg = "Error: Insufficient permissions to write to cache ({}).".format(cache_path) + write_log(msg) + raise PermissionError(msg) # Define the caching functions def write_cache(ip_addr): now = datetime.datetime.now().isoformat(' ', timespec='seconds') - with open(conf["cache_path"], 'w') as f: + with open(cache_path, 'w') as f: f.write("[{}]: {}".format(now, ip_addr)) def read_cache(): - with open(conf["cache_path"], "r") as f: + with open(cache_path, "r") as f: cached = f.readline() return (cached[1:20], cached[23:]) # date_time, ip_addr else: # No cache file specified, so disable caching and warn the user! - write_log("Warning: No cache file specified, so the IP address will always be submitted as if new - this could be considered abusive!") + msg = ("Warning: No cache file specified, so the IP address will always be submitted " + "as if new - this could be considered abusive!") + write_log(msg) + warnings.warn(msg) # Define the caching functions def write_cache(ip_addr): @@ -88,11 +88,12 @@ def read_cache(): return (None, None) # Get IPv4 address - myip = pif.get_public_ip("v4.ident.me") + myip = pif.get_public_ip("v4.ident.me") # Enforce IPv4 (for now) if not myip: - write_log("Error: Failed to determine IPv4 address") - sys.exit(errno.CONNREFUSED) + msg = "Error: Failed to determine IPv4 address" + write_log(msg) + raise ConnectionError(msg) # Check whether the current IP is equal to the cached IP address date_time, cached_ip = read_cache() @@ -101,63 +102,125 @@ def read_cache(): elif myip == cached_ip: # Already up-to-date, so log it and exit write_log("Success: IP address is already up-to-date ({})".format(myip)) - sys.exit(0) + return (False, myip, None) else: write_log("Info: New IP address detected ({})".format(myip)) # Get API details - account = Account(api_key=conf.get("api_key"), api_secret=conf.get("api_secret")) - client = Client(account, api_base_url=conf.get("api_base_url", "https://api.godaddy.com")) + api_key = config.get("api_key") + api_secret = config.get("api_secret") + + # Check that they have values + missing_cred = [] + if not api_key: + missing_cred.append("'api_key'") + if not api_secret: + missing_cred.append("'api_secret'") + + if missing_cred: + msg = "Error: Missing credentials - {} must be specified".format(" and ".join(missing_cred)) + write_log(msg) + raise ConfigError(msg) + + # Initialise the connection classes + account = Account(api_key=config.get("api_key"), api_secret=config.get("api_secret")) + client = Client(account, api_base_url=config.get("api_base_url", "https://api.godaddy.com")) # Check that we have a connection and get the set of available domains try: available_domains = set(client.get_domains()) except BadResponse as e: - write_log("Error: Bad response from GoDaddy ({})".format(e._message)) - sys.exit(errno.CONNREFUSED) + msg = "Error: Bad response from GoDaddy ({})".format(e._message) + write_log(msg) + raise BadResponse(msg) # Make the API requests to update the IP address - failed_domains = {} # Stores a set of failed domains - failures will be tolerated but logged - for target in conf.get("targets", []): + failed_domains = set() # Stores a set of failed domains - failures will be tolerated but logged + succeeded_domains = [] + forced = "forcefully " if force else "" + + for target in config.get("targets", []): try: target_domain = target["domain"] except KeyError: - write_log("Error: Missing 'domain' in confuration file") - sys.exit(errno.EINVAL) + msg = "Error: Missing 'domain' for target in configuration file" + write_log(msg) + raise ConfigError(msg) - if type(target_domain) == str: - target_domain = {target_domain} + if isinstance(target_domain, str): + target_domain = {target_domain} # set of one element else: - target_domain = set(target_domain) + target_domain = set(target_domain) # set of supplied targets unknown_domains = target_domain - available_domains failed_domains.update(unknown_domains) domains = list(target_domain & available_domains) # Remove unknown domains + if not domains: + continue # No known domains, so don't bother contacting GoDaddy + subdomains = target.get("alias", "@") # Default to no subdomain (GoDaddy uses "@" for this) try: update_succeeded = client.update_ip(myip, domains=domains, subdomains=subdomains) except BadResponse as e: - write_log("Error: Bad response from GoDaddy ({})".format(e._message)) - sys.exit(errno.CONNREFUSED) + msg = "Error: Bad response from GoDaddy ({})".format(e._message) + write_log(msg) + raise BadResponse(msg) if update_succeeded: - write_log("Success: Updated IP for {subs}.{doms}".format(subs=subdomains, doms=domains)) + succeeded_domains.append("{subs}.{doms}".format(subs=subdomains, doms=domains)) + write_log("Success: IP address {}updated to {} for {}.".format(forced, + myip, + succeeded_domains[-1])) else: - write_log("Error: Unknown failure for (domain(s): {doms}, alias(es): {subs})".format(doms=target_domain, subs=subdomains)) - sys.exit(errno.CONNREFUSED) + msg = "Error: Unknown failure for (domain(s): {doms}, alias(es): {subs})".format( + doms=target_domain, subs=subdomains) + write_log(msg) + raise BadResponse(msg) if failed_domains: - write_log("Warning: The following domains were not found {}".format(failed_domains)) + msg = "Warning: The following domains were not found {}".format(failed_domains) + write_log(msg) + warnings.warn(msg) - # Write the new IP address to the cache and exit + # Write the new IP address to the cache and return write_cache(myip) - sys.exit(0) + return (True, myip, succeeded_domains) + + +def print_colourised(msg): + """Print messages automatically colourised based on initial keywords + + Currently supports: "Success", "Info", "Warning", "Error", None + """ + if msg.startswith("Success"): + style = { + "fg": "green", + "bold": True, + } + elif msg.startswith("Info"): + style = { + "fg": "blue", + } + elif msg.startswith("Warning"): + style = { + "fg": "yellow", + } + elif msg.startswith("Error"): + style = { + "fg": "red", + "bold": True, + } + else: + style = {} # Don't apply a special style + + click.echo(click.style(msg, **style)) -# Define 'touch' function def touch(path): + """Touch a path, creating it if necessary + """ # Ensure the path exists os.makedirs(os.path.dirname(path), exist_ok=True) # Create the file if necessary diff --git a/setup.py b/setup.py index 826c625..4fde440 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup(name='godaddy-ddns', - version='0.1', + version='0.2', description='DDNS-like update service for GoDaddy', url='http://github.com/N-Parsons/godaddy-ddns', author='Nathan Parsons',