Skip to content

Instantly share code, notes, and snippets.

@dcode
Last active June 24, 2025 20:19
Show Gist options
  • Save dcode/c0d4df69ef941258467c8116f552037c to your computer and use it in GitHub Desktop.
Save dcode/c0d4df69ef941258467c8116f552037c to your computer and use it in GitHub Desktop.
A Python script to update A and/or AAAA records in CloudFlare DNS from Synology DDNS on DSM >= 7.2.

Readme

Installation

To use this, connect to your Synology unit using SSH. Download the installer script and execute with root privileges (e.g. with sudo).

NOTE:: /tmp/ is mounted with noexec, so you can't run the script from there, hence /var/tmp.

cd /var/tmp
curl -LOJ \
  'https://gist.githubusercontent.com/dcode/c0d4df69ef941258467c8116f552037c/raw/installer.sh'
chmod +x installer.sh

# run it
sudo ./installer.sh

# cleanup
rm ./installer.sh

Configuration

To configure the DDNS service, follow the instructions for DSM 7.x. You'll need an API token from Cloudflare with permissions to read Zone.Zone and to edit Zone.DNS. Then in the Synology configuration, use the zone name for the username, the API key for the password, and the desired hostname for the hostname.

Click 'Test Connection' and it should go through, though I make no warranty for your success. If it breaks in two, you get to keep both pieces.

image

#!/bin/bash
## Ensure we're running as root or with sudo
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root" 1>&2
exit 1
fi
## Create virtualenv
if ! [ -d /var/lib/cfddns_venv ]; then
(cd /var/lib; python3 -m venv cfddns_venv)
fi
## Install cloudflare library in venv
(. /var/lib/cfddns_venv/bin/activate; pip3 install --upgrade pip; pip3 install --upgrade "cloudflare>=4.3.0,<4.4.0")
## Install latest DDNS script from gist to /sbin/syno_cloudflare_ddns.py
curl --silent -o /sbin/syno_cloudflare_ddns.py \
'https://gist.githubusercontent.com/dcode/c0d4df69ef941258467c8116f552037c/raw/syno_cloudflare_ddns.py'
chmod +x /sbin/syno_cloudflare_ddns.py
if ! grep --silent '^\[Cloudflare\]' /etc.defaults/ddns_provider.conf; then
echo "Adding DDNS Provider entry for Cloudflare" > /dev/stderr
cat <<EOF | sudo tee -a /etc.defaults/ddns_provider.conf
[Cloudflare]
modulepath=/sbin/syno_cloudflare_ddns.py
queryurl=https://www.cloudflare.com
website=https://www.cloudflare.com
EOF
else
echo "Cloudflare DDNS entry already present." > /dev/stderr
fi
#!/var/lib/cfddns_venv/bin/python3
# -*- coding: utf-8 -*-
import sys
import re
import subprocess
import ipaddress # Requires Python 3.3+ (Synology DSM 7.2 has Python 3.8)
import logging
try:
import cloudflare
except ImportError:
print("ERROR: cloudflare library not found. Please install it: pip install cloudflare")
sys.exit(1)
# --- Configuration ---
# Set to True if you want the script to try and find a global IPv6 address
# on the 'eth0' interface and update an AAAA record for it,
# ONLY if Synology provides an IPv4 address.
MANAGE_IPV6_SEPARATELY = True
ETH_INTERFACE_FOR_IPV6 = 'eth0' # Interface to check for IPv6 address
DEFAULT_PROXY_FOR_NEW_RECORDS = True # For new DNS records, should they be proxied by Cloudflare?
DEBUG = False # Set to True for verbose logging to stderr
# --- Helper Functions ---
def log_error(message):
print(f"ERROR: {message}", file=sys.stderr)
def log_debug(message):
if DEBUG:
print(f"DEBUG: {message}", file=sys.stderr)
def get_ip_type(ip_str):
"""Determines if an IP is IPv4 or IPv6 and returns record type ('A' or 'AAAA')."""
try:
ip_obj = ipaddress.ip_address(ip_str)
if isinstance(ip_obj, ipaddress.IPv4Address):
return "A"
elif isinstance(ip_obj, ipaddress.IPv6Address):
return "AAAA"
except ValueError:
log_debug(f"Invalid IP address format: {ip_str}")
return None
return None
def get_local_ipv6(interface):
"""Fetches a global scope IPv6 address from the specified local interface."""
if not MANAGE_IPV6_SEPARATELY:
return None
try:
# Command to list IP addresses for the interface
# We look for 'inet6' addresses with 'scope global'
# and extract the address itself (stripping the prefix length like /64)
cmd = ['ip', '-6', 'addr', 'show', 'dev', interface, 'scope', 'global']
log_debug(f"Running command to get local IPv6: {' '.join(cmd)}")
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True)
stdout, _ = process.communicate()
if process.returncode != 0:
log_debug(f"Error getting IPv6 address for {interface}: process returned {process.returncode}")
return None
# Regex to find global IPv6 addresses
# Example line: "inet6 2001:db8:abcd:0012::1/64 scope global dynamic noprefixroute"
ipv6_pattern = re.compile(r'inet6\s+([0-9a-fA-F:]+)/\d+\s+scope\s+global')
matches = ipv6_pattern.findall(stdout)
if matches:
log_debug(f"Found local IPv6 addresses: {matches}")
return matches[0] # Return the first one found
else:
log_debug(f"No global IPv6 address found for interface {interface}.")
return None
except FileNotFoundError:
log_debug(f"'ip' command not found. Cannot fetch local IPv6.")
return None
except Exception as e:
log_debug(f"Exception fetching local IPv6: {e}")
return None
def get_cf_zone_id(cf_client, zone_name):
"""Gets an existing DNS zone id from Cloudflare."""
log_debug(f"Getting CF record for {zone_name}")
try:
try:
zone_resp = cf_client.zones.list(name=zone_name)
except cloudflare.NotFoundError:
zone_resp = None
if zone_resp:
record = zone_resp.result[0]
log_debug(f"Found zone id: {record.id} for {record.name}")
return record.id
log_debug("No existing zone record found.")
return None
except cloudflare.APIError as e:
# Error 1003: Invalid or missing zone_id
# Error 7003: Could not find zone for this domain
# Error 81044: Record not found (this can be specific for GET by ID, less so for GET by name/type)
# A successful GET for a non-existent record returns an empty list, not an API error.
# So, specific error handling here is more for broader API issues.
log_debug(f"Cloudflare API error while getting zone id: {e}")
raise
except Exception as e:
log_debug(f"Unexpected error getting CF zone: {e}")
raise # Re-raise unexpected errors
def get_cf_record(cf_client, zone_id, hostname, record_type):
"""Gets an existing DNS record from Cloudflare."""
log_debug(f"Getting CF record for {hostname}, type {record_type}")
try:
params = {'type': record_type}
try:
dns_records = cf_client.dns.records.list(zone_id=zone_id, name=hostname, **params)
except cloudflare.NotFoundError as e:
log_debug(f"NotFoundError {e}")
dns_records = None
if dns_records and dns_records.result:
record = dns_records.result[0]
log_debug(f"Found record: {record}")
return {'id': record.id, 'content': record.content, 'proxied': record.proxied}
log_debug("No existing record found.")
return None
except cloudflare.APIError as e:
# Error 1003: Invalid or missing zone_id
# Error 7003: Could not find zone for this domain
# Error 81044: Record not found (this can be specific for GET by ID, less so for GET by name/type)
# A successful GET for a non-existent record returns an empty list, not an API error.
# So, specific error handling here is more for broader API issues.
log_debug(f"Cloudflare API error while getting record: {e}")
raise # Re-raise critical errors like bad zone ID
except Exception as e:
logging.exception("Unexpected error getting CF record.")
raise # Re-raise unexpected errors
def update_cf_record(cf_client, zone_id, record_id, hostname, record_type, ip_addr, proxied_status):
"""Updates an existing DNS record."""
log_debug(f"Updating CF record ID {record_id} ({hostname}, {record_type}) to IP {ip_addr}, Proxied: {proxied_status}")
data = {
'name': hostname,
'type': record_type,
'content': ip_addr,
'proxied': proxied_status,
'ttl': 1 # 1 = Auto
}
try:
cf_client.dns.records.update(dns_record_id=record_id, zone_id=zone_id, **data)
log_debug("Update successful.")
return True
except cloudflare.APIError as e:
log_debug(f"Cloudflare API error while updating record: {e}")
# Let main handler decide output based on error code
raise
except Exception as e:
logging.exception("Unexpected error updating CF record")
raise
def create_cf_record(cf_client, zone_id, hostname, record_type, ip_addr, proxied_status):
"""Creates a new DNS record."""
log_debug(f"Creating new CF record ({hostname}, {record_type}) with IP {ip_addr}, Proxied: {proxied_status}")
data = {
'name': hostname,
'type': record_type,
'content': ip_addr,
'proxied': proxied_status,
'ttl': 1 # 1 = Auto
}
try:
cf_client.dns.records.create(zone_id=zone_id, **data)
log_debug("Creation successful.")
return True
except cloudflare.APIError as e:
log_debug(f"Cloudflare API error while creating record: {e}")
# Let main handler decide output based on error code
raise
except Exception as e:
logging.exception("Unexpected error creating CF record.")
raise
# --- Main Script Logic ---
def main():
if not (5 <= len(sys.argv) <= 6): # script_name, zone_name, token, hostname, ip, (optional_debug)
log_error(f"Usage: {sys.argv[0]} <ZoneName> <APIToken> <Hostname> <IPAddress> [debug]")
print("badparam") # Synology might expect specific error strings
sys.exit(1)
zone_name = sys.argv[1]
api_token = sys.argv[2]
hostname = sys.argv[3]
syno_ip = sys.argv[4]
if len(sys.argv) == 6 and sys.argv[5].lower() == 'debug':
global DEBUG
DEBUG = True
log_debug("Debug mode enabled.")
log_debug(f"Zone Name: {zone_name}, Hostname: {hostname}, Synology IP: {syno_ip}")
syno_ip_type = get_ip_type(syno_ip)
if not syno_ip_type:
print("invalid_ip")
sys.exit(1)
try:
cf = cloudflare.Cloudflare(api_token=api_token)
except Exception as e:
log_error(f"Failed to initialize CloudFlare client: {e}")
print("badauth") # Could be bad token format or other init issues
sys.exit(1)
ip_updated_syno = False
ip_updated_local_ipv6 = False
# Get zone_id
try:
zone_id = get_cf_zone_id(cf, zone_name)
except Exception as e:
log_error(f"Failed to retrieve zone id for {zone_name}. Error: {e}")
print("911")
sys.exit(1)
# Handle the IP address provided by Synology
try:
log_debug(f"Processing Synology IP: {syno_ip} (Type: {syno_ip_type})")
current_record_syno = get_cf_record(cf, zone_id, hostname, syno_ip_type)
if current_record_syno is None:
log_debug(f"No existing {syno_ip_type} record for {hostname}. Creating.")
if create_cf_record(cf, zone_id, hostname, syno_ip_type, syno_ip, DEFAULT_PROXY_FOR_NEW_RECORDS):
ip_updated_syno = True
elif current_record_syno['content'] != syno_ip:
log_debug(f"Existing {syno_ip_type} record IP ({current_record_syno['content']}) differs from Synology IP ({syno_ip}). Updating.")
if update_cf_record(cf, zone_id, current_record_syno['id'], hostname, syno_ip_type, syno_ip, current_record_syno['proxied']):
ip_updated_syno = True
else:
log_debug(f"Synology IP ({syno_ip}) matches existing {syno_ip_type} record. No change needed for this record.")
# If Synology provided an IPv4, and MANAGE_IPV6_SEPARATELY is true, try to update AAAA
if syno_ip_type == "A" and MANAGE_IPV6_SEPARATELY:
local_ipv6 = get_local_ipv6(ETH_INTERFACE_FOR_IPV6)
if local_ipv6:
log_debug(f"Processing local IPv6: {local_ipv6} (Type: AAAA)")
current_record_aaaa = get_cf_record(cf, zone_id, hostname, "AAAA")
if current_record_aaaa is None:
log_debug(f"No existing AAAA record for {hostname}. Creating with local IPv6.")
if create_cf_record(cf, zone_id, hostname, "AAAA", local_ipv6, DEFAULT_PROXY_FOR_NEW_RECORDS):
ip_updated_local_ipv6 = True
elif current_record_aaaa['content'] != local_ipv6:
log_debug(f"Existing AAAA record IP ({current_record_aaaa['content']}) differs from local IPv6 ({local_ipv6}). Updating.")
if update_cf_record(cf, zone_id, current_record_aaaa['id'], hostname, "AAAA", local_ipv6, current_record_aaaa['proxied']):
ip_updated_local_ipv6 = True
else:
log_debug(f"Local IPv6 ({local_ipv6}) matches existing AAAA record. No change needed for this record.")
else:
log_debug("No local IPv6 address obtained, or MANAGE_IPV6_SEPARATELY is false.")
if ip_updated_syno or ip_updated_local_ipv6:
print("good")
else:
print("nochg")
except cloudflare.APIError as e:
log_debug(f"Caught Cloudflare APIError: {e}")
# Common Auth Errors:
# 6003: Invalid request headers (often implies bad X-Auth-Email/Key format, but we use token)
# 9103: Unknown X-Auth-Key or X-Auth-Email (implies bad token if it were key/email)
# 10000: Authentication error (generic, can be bad token)
# 7000: All validation errors (could be malformed token before even API call)
# For token auth, error might be less specific until an actual API call is made with it.
# A common token issue message might be "Invalid API token" (error code 10000)
# Zone related:
# 1003: Invalid or missing zone_id / 7003: Could not find zone for this domain
# (For token: if token does not have permission for the zone_id)
# Heuristic for "badauth"
# This is a simplification. Cloudflare error codes are numerous.
if error_code in [10000, 9103, 6003, 7000] or "Invalid API token" in str(e) or "Authentication error" in str(e):
print("badauth")
elif error_code in [1003, 7003] or "Invalid zone identifier" in str(e): # Specific to zone issues
print("nohost")
else:
print(f"911")
except Exception as e:
log_debug(f"Caught unexpected exception: {e}")
print("911")
finally:
log_debug("Script finished.")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment