Added Cloudflare service to DDNS

This commit is contained in:
Matthew Grotke 2026-05-14 23:45:24 -04:00
parent 82f3058577
commit df09e99888
4 changed files with 170 additions and 54 deletions

135
ddns.py
View file

@ -80,6 +80,8 @@ def load_config():
extra = {"username", "password", "hostnames"}
elif ptype == "duckdns":
extra = {"token", "subdomains"}
elif ptype == "cloudflare":
extra = {"api_token", "hostnames"}
else:
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'")
sys.exit(1)
@ -181,11 +183,16 @@ def save_service_index(index):
def extract_ip(body):
"""
Extract an IP address from a service response.
Handles both plain text responses (most services) and HTML responses
such as checkip.dyndns.org which returns:
<html>...<body>Current IP Address: 1.2.3.4</body></html>
Handles plain text, key=value format (e.g. Cloudflare /cdn-cgi/trace where
the ip= line is the caller's IP while h= is the server's IP), and HTML.
"""
# Try plain text first (strip and validate)
# Check for key=value format first (e.g. /cdn-cgi/trace)
for line in body.splitlines():
if line.startswith("ip="):
candidate = line[3:].strip()
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', candidate):
return candidate
# Try plain text (strip and validate)
plain = body.strip()
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', plain):
return plain
@ -196,6 +203,28 @@ def extract_ip(body):
return None
def _get_ip_via_cf_dns(spec):
"""Query Cloudflare's myip.cloudflare via DNS TXT (chaos class) for the caller's IP.
spec format: 'cf-dns:<hostname>' e.g. 'cf-dns:myip.cloudflare'
Requires the 'dig' utility to be installed.
"""
hostname = spec[len("cf-dns:"):]
cmd = ["dig", "+short", "@1.1.1.1", "chaos", "txt", hostname]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return None
# TXT records come back quoted: "203.0.113.50"
ip = result.stdout.strip().strip('"').split()[0]
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', ip):
return ip
except FileNotFoundError:
log.warning("'dig' command not found; cannot use cf-dns IP check service.")
except Exception:
pass
return None
# ------------------------------------------------------------------------------
def get_public_ip(services):
@ -208,19 +237,22 @@ def get_public_ip(services):
start = get_next_service_index(total)
ordered = [services[(start + i) % total] for i in range(total)]
for i, url in enumerate(ordered):
for i, service in enumerate(ordered):
try:
req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"})
with urllib.request.urlopen(req, timeout=10) as r:
body = r.read().decode().strip()
ip = extract_ip(body)
if ip:
used_index = (start + i) % total
save_service_index(used_index)
log.info(f"Public IP retrieved from {url}: {ip}")
return ip
if service.startswith("cf-dns:"):
ip = _get_ip_via_cf_dns(service)
else:
req = urllib.request.Request(service, headers={"User-Agent": "ddns-update/1.0"})
with urllib.request.urlopen(req, timeout=10) as r:
body = r.read().decode().strip()
ip = extract_ip(body)
if ip:
used_index = (start + i) % total
save_service_index(used_index)
log.info(f"Public IP retrieved from {service}: {ip}")
return ip
except Exception as e:
log.warning(f"IP check failed for {url}: {e}")
log.warning(f"IP check failed for {service}: {e}")
continue
log.error("Could not determine public IP from any configured service.")
@ -326,6 +358,77 @@ def update_duckdns(provider, ip):
log.error(f"Network error contacting DuckDNS: {e}")
return False
# ------------------------------------------------------------------------------
# Cloudflare DNS update
# ------------------------------------------------------------------------------
def _cf_api_get(url, headers):
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read().decode())
except Exception as e:
log.error(f"Cloudflare API GET error ({url}): {e}")
return None
def _cf_get_zone_id(zone_name, headers):
data = _cf_api_get(
f"https://api.cloudflare.com/client/v4/zones?name={zone_name}", headers
)
if data and data.get("success") and data["result"]:
return data["result"][0]["id"]
return None
def _cf_get_record_id(zone_id, hostname, headers):
data = _cf_api_get(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?name={hostname}&type=A",
headers,
)
if data and data.get("success") and data["result"]:
return data["result"][0]["id"]
return None
def update_cloudflare(provider, ip):
"""
Cloudflare DNS update API.
Docs: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
Bearer-token auth. Looks up zone and record IDs dynamically, then PATCHes each A record.
"""
token = provider["api_token"]
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"User-Agent": "ddns-update/1.0",
}
success = True
for hostname in provider["hostnames"]:
zone_name = ".".join(hostname.split(".")[-2:])
zone_id = _cf_get_zone_id(zone_name, headers)
if not zone_id:
log.error(f"Cloudflare: zone '{zone_name}' not found in account.")
success = False
continue
record_id = _cf_get_record_id(zone_id, hostname, headers)
if not record_id:
log.error(f"Cloudflare: A record for '{hostname}' not found in zone '{zone_name}'.")
success = False
continue
url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}"
payload = json.dumps({"content": ip}).encode()
req = urllib.request.Request(url, data=payload, headers=headers, method="PATCH")
try:
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read().decode())
if data.get("success"):
log.info(f"Cloudflare updated successfully: {hostname} -> {ip}")
else:
log.error(f"Cloudflare update failed for '{hostname}': {data.get('errors')}")
success = False
except Exception as e:
log.error(f"Cloudflare API PATCH error for '{hostname}': {e}")
success = False
return success
# ------------------------------------------------------------------------------
# Process a single provider block
# ------------------------------------------------------------------------------
@ -358,6 +461,8 @@ def process_provider(provider, current_ip, force=False):
success = interpret_noip_response(response, hostnames, current_ip)
elif ptype == "duckdns":
success = update_duckdns(provider, current_ip)
elif ptype == "cloudflare":
success = update_cloudflare(provider, current_ip)
else:
log.error(f"[{description}] Unknown provider type: '{ptype}'")
return