11import datetime
2- import errno
32import os
4- import re
5- import sys
3+ import warnings
64
75import click
86import pif
1210from godaddypy .client import BadResponse
1311
1412
15- @click .command ()
16- @click .option (
17- '--config' ,
18- default = "/etc/godaddy-ddns/config.yaml" ,
19- help = "Path to configuration file (.yaml)." ,
20- type = click .File ('r' )
21- )
22- @click .option ('--force' , is_flag = True , help = "Update the IP regardless of the cached IP." )
23- @click .option ('--quiet' , is_flag = True , help = "Don't print to stdout." )
24- def update_ip (config , force , quiet ):
13+ class ConfigError (Exception ):
14+ pass
15+
16+
17+ def update_ip (config_file , force ):
18+ """Update the IP address for the configured domains/subdomains
19+
20+ Parameters:
21+ - config_file: Open file or file-like object configuration file
22+ - force: boolean flag for forcing updates (True => force update)
23+
24+ Returns:
25+ - updated: bool indicating whether the IP address was updated
26+ - myip: str containing the current IP address
27+ - domains: list of updated domains (eg. ["[sub1,sub2].[example.com]"])
28+ """
2529 # Load the configuration file
2630 try :
27- conf = yaml .load (config )
28- except yaml .MarkedYAMLError as e :
29- # If the error is marked, give the details
30- print ("Error: Malformed configuration file." )
31- print (e )
32- sys .exit (errno .EINVAL )
33- except yaml .YAMLError :
34- # Otherwise just state that it's malformed
35- print ("Error: Malformed configuration file" )
36- sys .exit (errno .EINVAL )
31+ config = yaml .load (config_file )
32+ except (yaml .MarkedYAMLError , yaml .YAMLError ) as e :
33+ raise ConfigError ("Error: {}" .format (e ))
3734
3835 # Check the supplied log path
39- if conf .get ("log_path" , False ):
36+ log_path = config .get ("log_path" )
37+ if log_path :
4038 # Make sure that the log path exists and is writable
4139 try :
42- touch (conf [ " log_path" ] )
40+ touch (log_path )
4341 except PermissionError :
44- print ( "Error: Insufficient permissions to write log to '{}'." .format (conf [ ' log_path' ]) )
45- sys . exit ( errno . EACCES )
42+ msg = "Error: Insufficient permissions to write log to '{}'." .format (log_path )
43+ raise PermissionError ( msg ) # Currently no log, so just raise an exception
4644
4745 # Define the logging function
4846 def write_log (msg ):
4947 now = datetime .datetime .now ().isoformat (' ' , timespec = 'seconds' )
50- with open (conf [ " log_path" ] , 'a' ) as f :
48+ with open (log_path , 'a' ) as f :
5149 f .write ("[{now}]: {msg}\n " .format (now = now , msg = msg ))
52- if not quiet :
53- click .echo (msg )
5450 else :
5551 # No log file specified, so disable logging
5652 def write_log (msg ):
57- if not quiet :
58- click .echo (msg ) # Just print the message, don't log it
53+ pass
5954
6055 # Check the supplied cache path
61- if conf .get ("cache_path" , False ):
56+ cache_path = config .get ("cache_path" )
57+ if cache_path :
6258 # Make sure that the log path exists and is writable
6359 try :
64- touch (conf [ " cache_path" ] ) # Create the file if necessary
60+ touch (cache_path ) # Create the file if necessary
6561 except PermissionError :
66- write_log ("Error: Insufficient permissions to write to cache ({})." .format (conf ['cache_path' ]))
67- sys .exit (errno .EACCES )
62+ msg = "Error: Insufficient permissions to write to cache ({})." .format (cache_path )
63+ write_log (msg )
64+ raise PermissionError (msg )
6865
6966 # Define the caching functions
7067 def write_cache (ip_addr ):
7168 now = datetime .datetime .now ().isoformat (' ' , timespec = 'seconds' )
72- with open (conf [ " cache_path" ] , 'w' ) as f :
69+ with open (cache_path , 'w' ) as f :
7370 f .write ("[{}]: {}" .format (now , ip_addr ))
7471
7572 def read_cache ():
76- with open (conf [ " cache_path" ] , "r" ) as f :
73+ with open (cache_path , "r" ) as f :
7774 cached = f .readline ()
7875 return (cached [1 :20 ], cached [23 :]) # date_time, ip_addr
7976 else :
8077 # No cache file specified, so disable caching and warn the user!
81- write_log ("Warning: No cache file specified, so the IP address will always be submitted as if new - this could be considered abusive!" )
78+ msg = ("Warning: No cache file specified, so the IP address will always be submitted "
79+ "as if new - this could be considered abusive!" )
80+ write_log (msg )
81+ warnings .warn (msg )
8282
8383 # Define the caching functions
8484 def write_cache (ip_addr ):
@@ -88,11 +88,12 @@ def read_cache():
8888 return (None , None )
8989
9090 # Get IPv4 address
91- myip = pif .get_public_ip ("v4.ident.me" )
91+ myip = pif .get_public_ip ("v4.ident.me" ) # Enforce IPv4 (for now)
9292
9393 if not myip :
94- write_log ("Error: Failed to determine IPv4 address" )
95- sys .exit (errno .CONNREFUSED )
94+ msg = "Error: Failed to determine IPv4 address"
95+ write_log (msg )
96+ raise ConnectionError (msg )
9697
9798 # Check whether the current IP is equal to the cached IP address
9899 date_time , cached_ip = read_cache ()
@@ -101,63 +102,125 @@ def read_cache():
101102 elif myip == cached_ip :
102103 # Already up-to-date, so log it and exit
103104 write_log ("Success: IP address is already up-to-date ({})" .format (myip ))
104- sys . exit ( 0 )
105+ return ( False , myip , None )
105106 else :
106107 write_log ("Info: New IP address detected ({})" .format (myip ))
107108
108109 # Get API details
109- account = Account (api_key = conf .get ("api_key" ), api_secret = conf .get ("api_secret" ))
110- client = Client (account , api_base_url = conf .get ("api_base_url" , "https://api.godaddy.com" ))
110+ api_key = config .get ("api_key" )
111+ api_secret = config .get ("api_secret" )
112+
113+ # Check that they have values
114+ missing_cred = []
115+ if not api_key :
116+ missing_cred .append ("'api_key'" )
117+ if not api_secret :
118+ missing_cred .append ("'api_secret'" )
119+
120+ if missing_cred :
121+ msg = "Error: Missing credentials - {} must be specified" .format (" and " .join (missing_cred ))
122+ write_log (msg )
123+ raise ConfigError (msg )
124+
125+ # Initialise the connection classes
126+ account = Account (api_key = config .get ("api_key" ), api_secret = config .get ("api_secret" ))
127+ client = Client (account , api_base_url = config .get ("api_base_url" , "https://api.godaddy.com" ))
111128
112129 # Check that we have a connection and get the set of available domains
113130 try :
114131 available_domains = set (client .get_domains ())
115132 except BadResponse as e :
116- write_log ("Error: Bad response from GoDaddy ({})" .format (e ._message ))
117- sys .exit (errno .CONNREFUSED )
133+ msg = "Error: Bad response from GoDaddy ({})" .format (e ._message )
134+ write_log (msg )
135+ raise BadResponse (msg )
118136
119137 # Make the API requests to update the IP address
120- failed_domains = {} # Stores a set of failed domains - failures will be tolerated but logged
121- for target in conf .get ("targets" , []):
138+ failed_domains = set () # Stores a set of failed domains - failures will be tolerated but logged
139+ succeeded_domains = []
140+ forced = "forcefully " if force else ""
141+
142+ for target in config .get ("targets" , []):
122143 try :
123144 target_domain = target ["domain" ]
124145 except KeyError :
125- write_log ("Error: Missing 'domain' in confuration file" )
126- sys .exit (errno .EINVAL )
146+ msg = "Error: Missing 'domain' for target in configuration file"
147+ write_log (msg )
148+ raise ConfigError (msg )
127149
128- if type (target_domain ) == str :
129- target_domain = {target_domain }
150+ if isinstance (target_domain , str ) :
151+ target_domain = {target_domain } # set of one element
130152 else :
131- target_domain = set (target_domain )
153+ target_domain = set (target_domain ) # set of supplied targets
132154
133155 unknown_domains = target_domain - available_domains
134156 failed_domains .update (unknown_domains )
135157
136158 domains = list (target_domain & available_domains ) # Remove unknown domains
159+ if not domains :
160+ continue # No known domains, so don't bother contacting GoDaddy
161+
137162 subdomains = target .get ("alias" , "@" ) # Default to no subdomain (GoDaddy uses "@" for this)
138163
139164 try :
140165 update_succeeded = client .update_ip (myip , domains = domains , subdomains = subdomains )
141166 except BadResponse as e :
142- write_log ("Error: Bad response from GoDaddy ({})" .format (e ._message ))
143- sys .exit (errno .CONNREFUSED )
167+ msg = "Error: Bad response from GoDaddy ({})" .format (e ._message )
168+ write_log (msg )
169+ raise BadResponse (msg )
144170
145171 if update_succeeded :
146- write_log ("Success: Updated IP for {subs}.{doms}" .format (subs = subdomains , doms = domains ))
172+ succeeded_domains .append ("{subs}.{doms}" .format (subs = subdomains , doms = domains ))
173+ write_log ("Success: IP address {}updated to {} for {}." .format (forced ,
174+ myip ,
175+ succeeded_domains [- 1 ]))
147176 else :
148- write_log ("Error: Unknown failure for (domain(s): {doms}, alias(es): {subs})" .format (doms = target_domain , subs = subdomains ))
149- sys .exit (errno .CONNREFUSED )
177+ msg = "Error: Unknown failure for (domain(s): {doms}, alias(es): {subs})" .format (
178+ doms = target_domain , subs = subdomains )
179+ write_log (msg )
180+ raise BadResponse (msg )
150181
151182 if failed_domains :
152- write_log ("Warning: The following domains were not found {}" .format (failed_domains ))
183+ msg = "Warning: The following domains were not found {}" .format (failed_domains )
184+ write_log (msg )
185+ warnings .warn (msg )
153186
154- # Write the new IP address to the cache and exit
187+ # Write the new IP address to the cache and return
155188 write_cache (myip )
156- sys .exit (0 )
189+ return (True , myip , succeeded_domains )
190+
191+
192+ def print_colourised (msg ):
193+ """Print messages automatically colourised based on initial keywords
194+
195+ Currently supports: "Success", "Info", "Warning", "Error", None
196+ """
197+ if msg .startswith ("Success" ):
198+ style = {
199+ "fg" : "green" ,
200+ "bold" : True ,
201+ }
202+ elif msg .startswith ("Info" ):
203+ style = {
204+ "fg" : "blue" ,
205+ }
206+ elif msg .startswith ("Warning" ):
207+ style = {
208+ "fg" : "yellow" ,
209+ }
210+ elif msg .startswith ("Error" ):
211+ style = {
212+ "fg" : "red" ,
213+ "bold" : True ,
214+ }
215+ else :
216+ style = {} # Don't apply a special style
217+
218+ click .echo (click .style (msg , ** style ))
157219
158220
159- # Define 'touch' function
160221def touch (path ):
222+ """Touch a path, creating it if necessary
223+ """
161224 # Ensure the path exists
162225 os .makedirs (os .path .dirname (path ), exist_ok = True )
163226 # Create the file if necessary
0 commit comments