diff --git a/cloudflare-ddns.py b/cloudflare-ddns.py index 64dc6e9..ac86b5f 100755 --- a/cloudflare-ddns.py +++ b/cloudflare-ddns.py @@ -1,15 +1,14 @@ #!/usr/bin/env python3 -# cloudflare-ddns.py -# Summary: Access your home network remotely via a custom domain name without a static IP! -# Description: Access your home network remotely via a custom domain -# Access your home network remotely via a custom domain -# A small, 🕵️ privacy centric, and ⚡ -# lightning fast multi-architecture Docker image for self hosting projects. +# cloudflare-ddns.py +# Summary: Access your home network remotely via a custom domain name without a static IP! +# Description: Access your home network remotely via a custom domain +# Access your home network remotely via a custom domain +# A small, 🕵️ privacy centric, and ⚡ +# lightning fast multi-architecture Docker image for self hosting projects. __version__ = "1.0.2" from string import Template - import json import os import signal @@ -19,6 +18,7 @@ import requests CONFIG_PATH = os.environ.get('CONFIG_PATH', os.getcwd()) + # Read in all environment variables that have the correct prefix ENV_VARS = {key: value for (key, value) in os.environ.items() if key.startswith('CF_DDNS_')} @@ -32,26 +32,30 @@ def exit_gracefully(self, signum, frame): print("🛑 Stopping main thread...") self.kill_now.set() - def deleteEntries(type): # Helper function for deleting A or AAAA records # in the case of no IPv4 or IPv6 connection, yet # existing A or AAAA records are found. - for option in config["cloudflare"]: - answer = cf_api( - "zones/" + option['zone_id'] + - "/dns_records?per_page=100&type=" + type, - "GET", option) - if answer is None or answer["result"] is None: - time.sleep(5) - return - for record in answer["result"]: - identifier = str(record["id"]) - cf_api( - "zones/" + option['zone_id'] + "/dns_records/" + identifier, - "DELETE", option) - print("🗑️ Deleted stale record " + identifier) - + try: + for option in config["cloudflare"]: + answer = cf_api( + "zones/" + option['zone_id'] + "/dns_records?per_page=100&type=" + type, + "GET", + option + ) + if answer is None or answer["result"] is None: + time.sleep(5) + return + for record in answer["result"]: + identifier = str(record["id"]) + cf_api( + "zones/" + option['zone_id'] + "/dns_records/" + identifier, + "DELETE", + option + ) + print("🗑️ Deleted stale record " + identifier) + except Exception as e: + print(f"😡 Exception in deleteEntries for type '{type}': {e}") def getIPs(): a = None @@ -59,194 +63,205 @@ def getIPs(): global ipv4_enabled global ipv6_enabled global purgeUnknownRecords - if ipv4_enabled: - try: - a = requests.get( - "https://1.1.1.1/cdn-cgi/trace").text.split("\n") - a.pop() - a = dict(s.split("=") for s in a)["ip"] - except Exception: - global shown_ipv4_warning - if not shown_ipv4_warning: - shown_ipv4_warning = True - print("🧩 IPv4 not detected via 1.1.1.1, trying 1.0.0.1") - # Try secondary IP check + try: + if ipv4_enabled: try: a = requests.get( - "https://1.0.0.1/cdn-cgi/trace").text.split("\n") + "https://1.1.1.1/cdn-cgi/trace").text.split("\n") a.pop() a = dict(s.split("=") for s in a)["ip"] - except Exception: - global shown_ipv4_warning_secondary - if not shown_ipv4_warning_secondary: - shown_ipv4_warning_secondary = True - print("🧩 IPv4 not detected via 1.0.0.1. Verify your ISP or DNS provider isn't blocking Cloudflare's IPs.") + except Exception as e: + global shown_ipv4_warning + if not shown_ipv4_warning: + shown_ipv4_warning = True + print(f"🧩 IPv4 not detected via 1.1.1.1, trying 1.0.0.1. Error: {e}") + # Try secondary IP check + try: + a = requests.get( + "https://1.0.0.1/cdn-cgi/trace").text.split("\n") + a.pop() + a = dict(s.split("=") for s in a)["ip"] + except Exception as e: + global shown_ipv4_warning_secondary + if not shown_ipv4_warning_secondary: + shown_ipv4_warning_secondary = True + print(f"🧩 IPv4 not detected via 1.0.0.1. Error: {e}") + print("🧩 Verify your ISP or DNS provider isn't blocking Cloudflare's IPs.") if purgeUnknownRecords: deleteEntries("A") - if ipv6_enabled: - try: - aaaa = requests.get( - "https://[2606:4700:4700::1111]/cdn-cgi/trace").text.split("\n") - aaaa.pop() - aaaa = dict(s.split("=") for s in aaaa)["ip"] - except Exception: - global shown_ipv6_warning - if not shown_ipv6_warning: - shown_ipv6_warning = True - print("🧩 IPv6 not detected via 1.1.1.1, trying 1.0.0.1") + if ipv6_enabled: try: aaaa = requests.get( - "https://[2606:4700:4700::1001]/cdn-cgi/trace").text.split("\n") + "https://[2606:4700:4700::1111]/cdn-cgi/trace").text.split("\n") aaaa.pop() aaaa = dict(s.split("=") for s in aaaa)["ip"] - except Exception: - global shown_ipv6_warning_secondary - if not shown_ipv6_warning_secondary: - shown_ipv6_warning_secondary = True - print("🧩 IPv6 not detected via 1.0.0.1. Verify your ISP or DNS provider isn't blocking Cloudflare's IPs.") + except Exception as e: + global shown_ipv6_warning + if not shown_ipv6_warning: + shown_ipv6_warning = True + print(f"🧩 IPv6 not detected via 1.1.1.1, trying 1.0.0.1. Error: {e}") + try: + aaaa = requests.get( + "https://[2606:4700:4700::1001]/cdn-cgi/trace").text.split("\n") + aaaa.pop() + aaaa = dict(s.split("=") for s in aaaa)["ip"] + except Exception as e: + global shown_ipv6_warning_secondary + if not shown_ipv6_warning_secondary: + shown_ipv6_warning_secondary = True + print(f"🧩 IPv6 not detected via 1.0.0.1. Error: {e}") + print("🧩 Verify your ISP or DNS provider isn't blocking Cloudflare's IPs.") if purgeUnknownRecords: deleteEntries("AAAA") - ips = {} - if (a is not None): - ips["ipv4"] = { - "type": "A", - "ip": a - } - if (aaaa is not None): - ips["ipv6"] = { - "type": "AAAA", - "ip": aaaa - } - return ips - + ips = {} + if a is not None: + ips["ipv4"] = { + "type": "A", + "ip": a + } + if aaaa is not None: + ips["ipv6"] = { + "type": "AAAA", + "ip": aaaa + } + return ips + except Exception as e: + print(f"😡 Exception in getIPs: {e}") + return {} def commitRecord(ip): global ttl - for option in config["cloudflare"]: - subdomains = option["subdomains"] - response = cf_api("zones/" + option['zone_id'], "GET", option) - if response is None or response["result"]["name"] is None: - time.sleep(5) - return - base_domain_name = response["result"]["name"] - for subdomain in subdomains: - try: - name = subdomain["name"].lower().strip() - proxied = subdomain["proxied"] - except: - name = subdomain - proxied = option["proxied"] - fqdn = base_domain_name - # Check if name provided is a reference to the root domain - if name != '' and name != '@': - fqdn = name + "." + base_domain_name - record = { - "type": ip["type"], - "name": fqdn, - "content": ip["ip"], - "proxied": proxied, - "ttl": ttl - } - dns_records = cf_api( - "zones/" + option['zone_id'] + - "/dns_records?per_page=100&type=" + ip["type"], - "GET", option) - identifier = None - modified = False - duplicate_ids = [] - if dns_records is not None: - for r in dns_records["result"]: - if (r["name"] == fqdn): - if identifier: - if r["content"] == ip["ip"]: - duplicate_ids.append(identifier) + try: + for option in config["cloudflare"]: + subdomains = option["subdomains"] + response = cf_api("zones/" + option['zone_id'], "GET", option) + if response is None or response["result"]["name"] is None: + time.sleep(5) + return + base_domain_name = response["result"]["name"] + for subdomain in subdomains: + try: + name = subdomain["name"].lower().strip() + proxied = subdomain["proxied"] + except: + name = subdomain + proxied = option["proxied"] + fqdn = base_domain_name + # Check if name provided is a reference to the root domain + if name != '' and name != '@': + fqdn = name + "." + base_domain_name + record = { + "type": ip["type"], + "name": fqdn, + "content": ip["ip"], + "proxied": proxied, + "ttl": ttl + } + dns_records = cf_api( + "zones/" + option['zone_id'] + "/dns_records?per_page=100&type=" + ip["type"], + "GET", + option + ) + identifier = None + modified = False + duplicate_ids = [] + if dns_records is not None: + for r in dns_records["result"]: + if (r["name"] == fqdn): + if identifier: + if r["content"] == ip["ip"]: + duplicate_ids.append(identifier) identifier = r["id"] else: - duplicate_ids.append(r["id"]) - else: - identifier = r["id"] + identifier = r["id"] if r['content'] != record['content'] or r['proxied'] != record['proxied']: modified = True - if identifier: - if modified: - print("📡 Updating record " + str(record)) + if identifier: + if modified: + print("📡 Updating record " + str(record)) + response = cf_api( + "zones/" + option['zone_id'] + "/dns_records/" + identifier, + "PUT", + option, + {}, + record + ) + else: + print("➕ Adding new record " + str(record)) response = cf_api( - "zones/" + option['zone_id'] + - "/dns_records/" + identifier, - "PUT", option, {}, record) - else: - print("➕ Adding new record " + str(record)) - response = cf_api( - "zones/" + option['zone_id'] + "/dns_records", "POST", option, {}, record) - if purgeUnknownRecords: - for identifier in duplicate_ids: - identifier = str(identifier) - print("🗑️ Deleting stale record " + identifier) - response = cf_api( - "zones/" + option['zone_id'] + - "/dns_records/" + identifier, - "DELETE", option) - return True - + "zones/" + option['zone_id'] + "/dns_records", + "POST", + option, + {}, + record + ) + if purgeUnknownRecords: + for identifier in duplicate_ids: + identifier = str(identifier) + print("🗑️ Deleting stale record " + identifier) + response = cf_api( + "zones/" + option['zone_id'] + "/dns_records/" + identifier, + "DELETE", + option + ) + return True + except Exception as e: + print(f"😡 Exception in commitRecord: {e}") def updateLoadBalancer(ip): - - for option in config["load_balancer"]: - pools = cf_api('user/load_balancers/pools', 'GET', option) - - if pools: - idxr = dict((p['id'], i) for i, p in enumerate(pools['result'])) - idx = idxr.get(option['pool_id']) - - origins = pools['result'][idx]['origins'] - - idxr = dict((o['name'], i) for i, o in enumerate(origins)) - idx = idxr.get(option['origin']) - - origins[idx]['address'] = ip['ip'] - data = {'origins': origins} - - response = cf_api(f'user/load_balancers/pools/{option["pool_id"]}', 'PATCH', option, {}, data) - + try: + for option in config["load_balancer"]: + pools = cf_api('user/load_balancers/pools', 'GET', option) + if pools: + idxr = dict((p['id'], i) for i, p in enumerate(pools['result'])) + idx = idxr.get(option['pool_id']) + origins = pools['result'][idx]['origins'] + idxr = dict((o['name'], i) for i, o in enumerate(origins)) + idx = idxr.get(option['origin']) + origins[idx]['address'] = ip['ip'] + data = {'origins': origins} + response = cf_api(f'user/load_balancers/pools/{option["pool_id"]}', 'PATCH', option, {}, data) + except Exception as e: + print(f"😡 Exception in updateLoadBalancer: {e}") def cf_api(endpoint, method, config, headers={}, data=False): - api_token = config['authentication']['api_token'] - if api_token != '' and api_token != 'api_token_here': - headers = { - "Authorization": "Bearer " + api_token, **headers - } - else: - headers = { - "X-Auth-Email": config['authentication']['api_key']['account_email'], - "X-Auth-Key": config['authentication']['api_key']['api_key'], - } try: - if (data == False): + api_token = config['authentication']['api_token'] + if api_token != '' and api_token != 'api_token_here': + headers = { + "Authorization": "Bearer " + api_token, + **headers + } + else: + headers = { + "X-Auth-Email": config['authentication']['api_key']['account_email'], + "X-Auth-Key": config['authentication']['api_key']['api_key'], + } + if not data: response = requests.request( - method, "https://api.cloudflare.com/client/v4/" + endpoint, headers=headers) + method, "https://api.cloudflare.com/client/v4/" + endpoint, headers=headers + ) else: response = requests.request( - method, "https://api.cloudflare.com/client/v4/" + endpoint, - headers=headers, json=data) - + method, "https://api.cloudflare.com/client/v4/" + endpoint, headers=headers, json=data + ) if response.ok: return response.json() else: - print("😡 Error sending '" + method + - "' request to '" + response.url + "':") + print(f"😡 Error sending '{method}' request to '{response.url}':") print(response.text) return None except Exception as e: - print("😡 An exception occurred while sending '" + - method + "' request to '" + endpoint + "': " + str(e)) + print(f"😡 An exception occurred while sending '{method}' request to '{endpoint}': {e}") return None - def updateIPs(ips): - for ip in ips.values(): - commitRecord(ip) - #updateLoadBalancer(ip) - + try: + for ip in ips.values(): + commitRecord(ip) + # updateLoadBalancer(ip) + except Exception as e: + print(f"😡 Exception in updateIPs: {e}") if __name__ == '__main__': shown_ipv4_warning = False @@ -256,10 +271,6 @@ def updateIPs(ips): ipv4_enabled = True ipv6_enabled = True purgeUnknownRecords = False - - if sys.version_info < (3, 5): - raise Exception("🐍 This script requires Python 3.5+") - config = None try: with open(os.path.join(CONFIG_PATH, "config.json")) as config_file: @@ -267,53 +278,66 @@ def updateIPs(ips): config = json.loads(Template(config_file.read()).safe_substitute(ENV_VARS)) else: config = json.loads(config_file.read()) - except: - print("😡 Error reading config.json") - # wait 10 seconds to prevent excessive logging on docker auto restart - time.sleep(10) + except Exception as e: + print(f"😡 Error reading config.json: {e}") + + # wait 10 seconds to prevent excessive logging on docker auto restart + time.sleep(10) if config is not None: try: ipv4_enabled = config["a"] - ipv6_enabled = config["aaaa"] - except: + except Exception as e: ipv4_enabled = True + print(f"⚙️ Error reading 'a' from config.json: {e}") + print("⚙️ Individually disable IPv4 or IPv6 with new config.json options. Read more about it here: https://github.com/timothymiller/cloudflare-ddns/blob/master/README.md") + try: + ipv6_enabled = config["aaaa"] + except Exception as e: ipv6_enabled = True + print(f"⚙️ Error reading 'aaaa' from config.json: {e}") print("⚙️ Individually disable IPv4 or IPv6 with new config.json options. Read more about it here: https://github.com/timothymiller/cloudflare-ddns/blob/master/README.md") try: purgeUnknownRecords = config["purgeUnknownRecords"] - except: + except Exception as e: purgeUnknownRecords = False - print("⚙️ No config detected for 'purgeUnknownRecords' - defaulting to False") + print(f"⚙️ No config detected for 'purgeUnknownRecords' - defaulting to False. Error: {e}") try: ttl = int(config["ttl"]) - except: + except Exception as e: ttl = 300 # default Cloudflare TTL - print( - "⚙️ No config detected for 'ttl' - defaulting to 300 seconds (5 minutes)") + print(f"⚙️ No config detected for 'ttl' - defaulting to 300 seconds (5 minutes). Error: {e}") if ttl < 30: - ttl = 1 # - print("⚙️ TTL is too low - defaulting to 1 (auto)") - if (len(sys.argv) > 1): - if (sys.argv[1] == "--repeat"): - if ipv4_enabled and ipv6_enabled: - print( - "🕰️ Updating IPv4 (A) & IPv6 (AAAA) records every " + str(ttl) + " seconds") - elif ipv4_enabled and not ipv6_enabled: - print("🕰️ Updating IPv4 (A) records every " + - str(ttl) + " seconds") - elif ipv6_enabled and not ipv4_enabled: - print("🕰️ Updating IPv6 (AAAA) records every " + - str(ttl) + " seconds") - next_time = time.time() - killer = GracefulExit() - prev_ips = None - while True: - updateIPs(getIPs()) - if killer.kill_now.wait(ttl): - break - else: - print("❓ Unrecognized parameter '" + - sys.argv[1] + "'. Stopping now.") + ttl = 1 + # print("⚙️ TTL is too low - defaulting to 1 (auto)") + else: + print("😡 Configuration is not loaded. Exiting.") + sys.exit(1) + + if (len(sys.argv) > 1): + if (sys.argv[1] == "--repeat"): + if ipv4_enabled and ipv6_enabled: + print(f"🕰️ Updating IPv4 (A) & IPv6 (AAAA) records every {str(ttl)} seconds") + elif ipv4_enabled and not ipv6_enabled: + print(f"🕰️ Updating IPv4 (A) records every {str(ttl)} seconds") + elif ipv6_enabled and not ipv4_enabled: + print(f"🕰️ Updating IPv6 (AAAA) records every {str(ttl)} seconds") + next_time = time.time() + killer = GracefulExit() + prev_ips = None + while True: + try: + ips = getIPs() + updateIPs(ips) + except Exception as e: + print(f"😡 Exception in main loop during update: {e}") + if killer.kill_now.wait(ttl): + break else: - updateIPs(getIPs()) + print("❓ Unrecognized parameter '" + sys.argv[1] + "'. Stopping now.") + else: + try: + ips = getIPs() + updateIPs(ips) + except Exception as e: + print(f"😡 Exception during single update: {e}")