🚀 Improvement: Skip PUT request when IP does not change

🧑‍🚀 Improvement: Working graceful exit
🚀 Improvement: Update readme on multiple zones
🐛 Fix: Handle IP changes correctly https://github.com/timothymiller/cloudflare-ddns/issues/37
This commit is contained in:
Timothy Miller 2021-02-26 01:15:35 -05:00
parent 6140917119
commit 4ffbb98f29
3 changed files with 117 additions and 62 deletions

View File

@ -18,7 +18,7 @@ This script was written for the Raspberry Pi platform to enable low cost, simple
## ⁉️ How Private & Secure? ## ⁉️ How Private & Secure?
1. Uses zero-log external IPv4 & IPv6 providers 1. Uses zero-log external IPv4 & IPv6 provider ([cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace))
2. Alpine Linux base image 2. Alpine Linux base image
3. HTTPS only via Python Software Foundation requests module 3. HTTPS only via Python Software Foundation requests module
4. Docker runtime 4. Docker runtime
@ -63,13 +63,54 @@ Alternatively, you can use the traditional API keys by setting appropriate value
"proxied": false (defaults to false. Make it true if you want CDN/SSL benefits from cloudflare. This usually disables SSH) "proxied": false (defaults to false. Make it true if you want CDN/SSL benefits from cloudflare. This usually disables SSH)
``` ```
## 📠 Hosting multiple domains on the same IP? ## 📠 Hosting multiple subdomains on the same IP?
You can save yourself some trouble when hosting multiple domains pointing to the same IP address (in the case of Traefik) by defining one A & AAAA record 'ddns.example.com' pointing to the IP of the server that will be updated by this DDNS script. For each subdomain, create a CNAME record pointing to 'ddns.example.com'. Now you don't have to manually modify the script config every time you add a new subdomain to your site! You can save yourself some trouble when hosting multiple domains pointing to the same IP address (in the case of Traefik) by defining one A & AAAA record 'ddns.example.com' pointing to the IP of the server that will be updated by this DDNS script. For each subdomain, create a CNAME record pointing to 'ddns.example.com'. Now you don't have to manually modify the script config every time you add a new subdomain to your site!
## Hosting multiple zones on the same IP?
You can handle ddns for multiple domains (cloudflare zones) using the same docker container by separating your configs inside ```config.json``` like below:
```bash
{
"cloudflare": [
{
"authentication": {
"api_token": "api_token_here",
"api_key": {
"api_key": "api_key_here",
"account_email": "your_email_here"
}
},
"zone_id": "your_zone_id_here",
"subdomains": [
"",
"subdomain"
],
"proxied": true
},
{
"authentication": {
"api_token": "api_token_here",
"api_key": {
"api_key": "api_key_here",
"account_email": "your_email_here"
}
},
"zone_id": "your_zone_id_here",
"subdomains": [
"",
"subdomain"
],
"proxied": true
}
]
}
```
## 🐳 Deploy with Docker Compose ## 🐳 Deploy with Docker Compose
Precompiled images are available via the official docker container [on DockerHub](https://hub.docker.com/r/timothyjmiller/cloudflare-ddns). Pre-compiled images are available via the official docker container [on DockerHub](https://hub.docker.com/r/timothyjmiller/cloudflare-ddns).
Modify the host file path of config.json inside the volumes section of docker-compose.yml. Modify the host file path of config.json inside the volumes section of docker-compose.yml.

View File

@ -1,5 +1,4 @@
import requests, json, sys, signal, os import requests, json, sys, signal, os, time
import time
PATH = os.getcwd() + "/" PATH = os.getcwd() + "/"
version = float(str(sys.version_info[0]) + "." + str(sys.version_info[1])) version = float(str(sys.version_info[0]) + "." + str(sys.version_info[1]))
@ -7,63 +6,79 @@ version = float(str(sys.version_info[0]) + "." + str(sys.version_info[1]))
if(version < 3.5): if(version < 3.5):
raise Exception("This script requires Python 3.5+") raise Exception("This script requires Python 3.5+")
def sigtermHandler(sig_no, stack_frame): class GracefulExit:
print("Caught SIGTERM, shutting down...") kill_now = False
sys.exit(0) signals = {
signal.SIGINT: 'SIGINT',
signal.SIGTERM: 'SIGTERM'
}
signal.signal(signal.SIGTERM, sigtermHandler) def __init__(self):
signal.signal(signal.SIGINT, self.exit_gracefully)
signal.signal(signal.SIGTERM, self.exit_gracefully)
def exit_gracefully(self, signum, frame):
print("\nReceived {} signal".format(self.signals[signum]))
print("Cleaning up resources. End of the program")
self.kill_now = True
with open(PATH + "config.json") as config_file: with open(PATH + "config.json") as config_file:
config = json.loads(config_file.read()) config = json.loads(config_file.read())
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.
try:
for c in config["cloudflare"]:
answer = cf_api(
"zones/" + c['zone_id'] + "/dns_records?per_page=100&type=" + type, "GET", c)
for r in answer["result"]:
identifier = str(r["id"])
response = cf_api(
"zones/" + c['zone_id'] + "/dns_records/" + identifier, "DELETE", c)
print("Deleted stale record " + identifier)
except Exception:
print("Error deleting " + type + " record(s)")
def getIPs(): def getIPs():
a = "" a = None
aaaa = "" aaaa = None
try: try:
a = requests.get("https://1.1.1.1/cdn-cgi/trace").text.split("\n") a = requests.get("https://1.1.1.1/cdn-cgi/trace").text.split("\n")
a.pop() a.pop()
a = dict(s.split("=") for s in a)["ip"] a = dict(s.split("=") for s in a)["ip"]
except Exception: except Exception:
print("Warning: IPv4 not detected.") print("Warning: IPv4 not detected.")
deleteEntries("A")
try: try:
aaaa = requests.get("https://[2606:4700:4700::1111]/cdn-cgi/trace").text.split("\n") aaaa = requests.get("https://[2606:4700:4700::1111]/cdn-cgi/trace").text.split("\n")
aaaa.pop() aaaa.pop()
aaaa = dict(s.split("=") for s in aaaa)["ip"] aaaa = dict(s.split("=") for s in aaaa)["ip"]
except Exception: except Exception:
print("Warning: IPv6 not detected.") print("Warning: IPv6 not detected.")
deleteEntries("AAAA")
ips = [] ips = []
if(a is not None):
if(a.find(".") > -1):
ips.append({ ips.append({
"type": "A", "type": "A",
"ip": a "ip": a
}) })
else: if(aaaa is not None):
print("Warning: IPv4 not detected.")
if(aaaa.find(":") > -1):
ips.append({ ips.append({
"type": "AAAA", "type": "AAAA",
"ip": aaaa "ip": aaaa
}) })
else:
print("Warning: IPv6 not detected.")
return ips return ips
def commitRecord(ip): def commitRecord(ip):
stale_record_ids = []
for c in config["cloudflare"]: for c in config["cloudflare"]:
subdomains = c["subdomains"] subdomains = c["subdomains"]
response = cf_api("zones/" + c['zone_id'], "GET", c) response = cf_api("zones/" + c['zone_id'], "GET", c)
base_domain_name = response["result"]["name"] base_domain_name = response["result"]["name"]
ttl = 120 ttl = 300 # default Cloudflare TTL
if "ttl" in c:
ttl=c["ttl"]
for subdomain in subdomains: for subdomain in subdomains:
subdomain = subdomain.lower() subdomain = subdomain.lower()
exists = False
record = { record = {
"type": ip["type"], "type": ip["type"],
"name": subdomain, "name": subdomain,
@ -71,41 +86,42 @@ def commitRecord(ip):
"proxied": c["proxied"], "proxied": c["proxied"],
"ttl": ttl "ttl": ttl
} }
list = cf_api( dns_records = cf_api(
"zones/" + c['zone_id'] + "/dns_records?per_page=100&type=" + ip["type"], "GET", c) "zones/" + c['zone_id'] + "/dns_records?per_page=100&type=" + ip["type"], "GET", c)
fqdn = base_domain_name
full_subdomain = base_domain_name
if subdomain: if subdomain:
full_subdomain = subdomain + "." + full_subdomain fqdn = subdomain + "." + base_domain_name
identifier = None
dns_id = "" modified = False
for r in list["result"]: duplicate_ids = []
if (r["name"] == full_subdomain): for r in dns_records["result"]:
exists = True if (r["name"] == fqdn):
if (r["content"] != ip["ip"]): if identifier:
if (dns_id == ""): if r["content"] == ip["ip"]:
dns_id = r["id"] duplicate_ids.append(identifier)
identifier = r["id"]
else:
duplicate_ids.append(r["id"])
else:
identifier = r["id"]
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/" + c['zone_id'] + "/dns_records/" + identifier, "PUT", c, {}, record)
else: else:
stale_record_ids.append(r["id"])
if(exists == False):
print("Adding new record " + str(record)) print("Adding new record " + str(record))
response = cf_api( response = cf_api(
"zones/" + c['zone_id'] + "/dns_records", "POST", c, {}, record) "zones/" + c['zone_id'] + "/dns_records", "POST", c, {}, record)
elif(dns_id != ""): for identifier in duplicate_ids:
# Only update if the record content is different identifier = str(identifier)
print("Updating record " + str(record)) print("Deleting stale record " + identifier)
response = cf_api(
"zones/" + c['zone_id'] + "/dns_records/" + dns_id, "PUT", c, {}, record)
# Delete duplicate, stale records
for identifier in stale_record_ids:
print("Deleting stale record " + str(identifier))
response = cf_api( response = cf_api(
"zones/" + c['zone_id'] + "/dns_records/" + identifier, "DELETE", c) "zones/" + c['zone_id'] + "/dns_records/" + identifier, "DELETE", c)
return True return True
def cf_api(endpoint, method, config, headers={}, data=False): def cf_api(endpoint, method, config, headers={}, data=False):
api_token = config['authentication']['api_token'] api_token = config['authentication']['api_token']
if api_token != '' and api_token != 'api_token_here': if api_token != '' and api_token != 'api_token_here':
@ -132,14 +148,14 @@ def updateIPs():
for ip in getIPs(): for ip in getIPs():
commitRecord(ip) commitRecord(ip)
try: if __name__ == '__main__':
if(len(sys.argv) > 1): if(len(sys.argv) > 1):
if(sys.argv[1] == "--repeat"): if(sys.argv[1] == "--repeat"):
print("Updating A & AAAA records every 10 minutes") delay = 5*60 # 5 minutes
updateIPs() print("Updating A & AAAA records every " + delay + " seconds")
delay = 10*60 # 10 minutes next_time = time.time()
next_time = time.time() + delay killer = GracefulExit()
while True: while not killer.kill_now:
time.sleep(max(0, next_time - time.time())) time.sleep(max(0, next_time - time.time()))
updateIPs() updateIPs()
next_time += (time.time() - next_time) // delay * delay + delay next_time += (time.time() - next_time) // delay * delay + delay
@ -147,5 +163,4 @@ try:
print("Unrecognized parameter '" + sys.argv[1] + "'. Stopping now.") print("Unrecognized parameter '" + sys.argv[1] + "'. Stopping now.")
else: else:
updateIPs() updateIPs()
except SystemExit:
print("Goodbye!")

View File

@ -13,8 +13,7 @@
"", "",
"subdomain" "subdomain"
], ],
"proxied": false, "proxied": false
"ttl": 120
} }
] ]
} }