|
#!/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() |