diff --git a/cloudflare-ddns.py b/cloudflare-ddns.py index 64dc6e9..052159d 100755 --- a/cloudflare-ddns.py +++ b/cloudflare-ddns.py @@ -17,10 +17,19 @@ import threading import time import requests +import functools -CONFIG_PATH = os.environ.get('CONFIG_PATH', os.getcwd()) +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_')} +ENV_VARS = { + key: value for (key, value) in os.environ.items() if key.startswith("CF_DDNS_") +} + +DEFAULT_TIMEOUT = 60 + +CLIENT = requests.Session() +CLIENT.request = functools.partial(CLIENT.request, timeout=DEFAULT_TIMEOUT) + class GracefulExit: def __init__(self): @@ -39,20 +48,36 @@ def deleteEntries(type): # 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) + "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) + "zones/" + option["zone_id"] + "/dns_records/" + identifier, + "DELETE", + option, + ) print("🗑️ Deleted stale record " + identifier) +class LogIps: + count = 0 + + def __init__(self): + pass + + @staticmethod + def log(ips): + if LogIps.count % 10 == 0: + print("📝 Detected IPs: " + str(ips)) + LogIps.count += 1 + + def getIPs(): a = None aaaa = None @@ -61,8 +86,7 @@ def getIPs(): global purgeUnknownRecords if ipv4_enabled: try: - a = requests.get( - "https://1.1.1.1/cdn-cgi/trace").text.split("\n") + a = CLIENT.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: @@ -72,21 +96,23 @@ def getIPs(): print("🧩 IPv4 not detected via 1.1.1.1, trying 1.0.0.1") # Try secondary IP check try: - a = requests.get( - "https://1.0.0.1/cdn-cgi/trace").text.split("\n") + a = CLIENT.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: 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.") + print( + "🧩 IPv4 not detected via 1.0.0.1. 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 = CLIENT.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: @@ -95,28 +121,26 @@ def getIPs(): shown_ipv6_warning = True print("🧩 IPv6 not detected via 1.1.1.1, trying 1.0.0.1") try: - aaaa = requests.get( - "https://[2606:4700:4700::1001]/cdn-cgi/trace").text.split("\n") + aaaa = CLIENT.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: 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.") + print( + "🧩 IPv6 not detected via 1.0.0.1. 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 - } + if a is not None: + ips["ipv4"] = {"type": "A", "ip": a} + if aaaa is not None: + ips["ipv6"] = {"type": "AAAA", "ip": aaaa} + LogIps.log(ips) return ips @@ -124,7 +148,7 @@ def commitRecord(ip): global ttl for option in config["cloudflare"]: subdomains = option["subdomains"] - response = cf_api("zones/" + option['zone_id'], "GET", option) + response = cf_api("zones/" + option["zone_id"], "GET", option) if response is None or response["result"]["name"] is None: time.sleep(5) return @@ -138,25 +162,29 @@ def commitRecord(ip): proxied = option["proxied"] fqdn = base_domain_name # Check if name provided is a reference to the root domain - if name != '' and name != '@': + if name != "" and name != "@": fqdn = name + "." + base_domain_name record = { "type": ip["type"], "name": fqdn, "content": ip["ip"], "proxied": proxied, - "ttl": ttl + "ttl": ttl, } dns_records = cf_api( - "zones/" + option['zone_id'] + - "/dns_records?per_page=100&type=" + ip["type"], - "GET", option) + "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 r["name"] == fqdn: if identifier: if r["content"] == ip["ip"]: duplicate_ids.append(identifier) @@ -165,90 +193,119 @@ def commitRecord(ip): duplicate_ids.append(r["id"]) else: identifier = r["id"] - if r['content'] != record['content'] or r['proxied'] != record['proxied']: + if ( + r["content"] != record["content"] + or r["proxied"] != record["proxied"] + ): modified = True if identifier: if modified: print("📡 Updating record " + str(record)) response = cf_api( - "zones/" + option['zone_id'] + - "/dns_records/" + identifier, - "PUT", option, {}, record) + "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) + "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) + "zones/" + option["zone_id"] + "/dns_records/" + identifier, + "DELETE", + option, + ) return True def updateLoadBalancer(ip): for option in config["load_balancer"]: - pools = cf_api('user/load_balancers/pools', 'GET', option) + 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']) + idxr = dict((p["id"], i) for i, p in enumerate(pools["result"])) + idx = idxr.get(option["pool_id"]) - origins = pools['result'][idx]['origins'] + origins = pools["result"][idx]["origins"] - idxr = dict((o['name'], i) for i, o in enumerate(origins)) - idx = idxr.get(option['origin']) + idxr = dict((o["name"], i) for i, o in enumerate(origins)) + idx = idxr.get(option["origin"]) - origins[idx]['address'] = ip['ip'] - data = {'origins': origins} + origins[idx]["address"] = ip["ip"] + data = {"origins": origins} - response = cf_api(f'user/load_balancers/pools/{option["pool_id"]}', 'PATCH', option, {}, data) + response = cf_api( + f'user/load_balancers/pools/{option["pool_id"]}', + "PATCH", + option, + {}, + data, + ) 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 - } + 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'], + "X-Auth-Email": config["authentication"]["api_key"]["account_email"], + "X-Auth-Key": config["authentication"]["api_key"]["api_key"], } try: - if (data == False): - response = requests.request( - method, "https://api.cloudflare.com/client/v4/" + endpoint, headers=headers) + if data == False: + response = CLIENT.request( + 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) + response = CLIENT.request( + 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( + "😡 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( + "😡 An exception occurred while sending '" + + method + + "' request to '" + + endpoint + + "': " + + str(e) + ) return None def updateIPs(ips): for ip in ips.values(): commitRecord(ip) - #updateLoadBalancer(ip) + # updateLoadBalancer(ip) -if __name__ == '__main__': +if __name__ == "__main__": shown_ipv4_warning = False shown_ipv4_warning_secondary = False shown_ipv6_warning = False @@ -264,7 +321,9 @@ def updateIPs(ips): try: with open(os.path.join(CONFIG_PATH, "config.json")) as config_file: if len(ENV_VARS) != 0: - config = json.loads(Template(config_file.read()).safe_substitute(ENV_VARS)) + config = json.loads( + Template(config_file.read()).safe_substitute(ENV_VARS) + ) else: config = json.loads(config_file.read()) except: @@ -279,32 +338,40 @@ def updateIPs(ips): except: ipv4_enabled = True ipv6_enabled = True - 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") + 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: purgeUnknownRecords = False - print("⚙️ No config detected for 'purgeUnknownRecords' - defaulting to False") + print( + "⚙️ No config detected for 'purgeUnknownRecords' - defaulting to False" + ) try: ttl = int(config["ttl"]) except: ttl = 300 # default Cloudflare TTL print( - "⚙️ No config detected for 'ttl' - defaulting to 300 seconds (5 minutes)") + "⚙️ No config detected for 'ttl' - defaulting to 300 seconds (5 minutes)" + ) 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 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") + "🕰️ 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") + 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") + print( + "🕰️ Updating IPv6 (AAAA) records every " + str(ttl) + " seconds" + ) next_time = time.time() killer = GracefulExit() prev_ips = None @@ -313,7 +380,6 @@ def updateIPs(ips): if killer.kill_now.wait(ttl): break else: - print("❓ Unrecognized parameter '" + - sys.argv[1] + "'. Stopping now.") + print("❓ Unrecognized parameter '" + sys.argv[1] + "'. Stopping now.") else: updateIPs(getIPs())