linuxrouter/router/ddns.py

654 lines
24 KiB
Python

#!/usr/bin/env python3
"""
ddns.py -- Update DDNS provider(s) with current public IP.
Reads ddns.json, fetches the current public IP, and updates
each enabled provider block only if the IP has changed since the
last successful update for that provider.
Designed to be run on a systemd timer.
IP check services are rotated each run using .ddns-last-service so
no single provider is spammed. If the selected service fails, the
script falls back through the remaining services in order.
Per-provider cache files are named .ddns-last-ip-<description>.
Logs to ddns.log in the same directory as this script.
Log is cleared when it exceeds general.log_max_kb from config.
Usage:
sudo python3 ddns.py --start Run update and install systemd timer
sudo python3 ddns.py --disable Stop updates and remove systemd timer
python3 ddns.py --apply Run update once (used by timer)
python3 ddns.py --force Force update regardless of cached IP
python3 ddns.py --status Show timer/service status
python3 ddns.py --getip Print current public IP and exit
"""
import json
import os
import subprocess
import re
import urllib.request
import urllib.error
import sys
import logging
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "ddns.json"
CACHE_SERVICE_FILE = SCRIPT_DIR / ".ddns-last-service"
LOG_FILE = SCRIPT_DIR / "ddns.log"
TIMER_NAME = "ddns-update"
SERVICE_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.service")
TIMER_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.timer")
# log is assigned in setup_logging() after config is loaded
log = None
# ===================================================================
# Load config
# ===================================================================
def load_config():
if not CONFIG_FILE.exists():
print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr)
sys.exit(1)
with open(CONFIG_FILE) as f:
data = json.load(f)
# Validate general block
required_general = {"log_max_kb", "log_errors_only", "ip_check_services"}
missing = required_general - set(data.get("general", {}).keys())
if missing:
print(f"ERROR: Missing keys in general block: {missing}", file=sys.stderr)
sys.exit(1)
if not data["general"]["ip_check_services"]:
print("ERROR: ip_check_services list is empty.", file=sys.stderr)
sys.exit(1)
# Validate providers block
if not data.get("providers"):
print("ERROR: No providers defined in config.", file=sys.stderr)
sys.exit(1)
for p in data["providers"]:
base_required = {"description", "provider", "enabled"}
missing = base_required - set(p.keys())
if missing:
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys: {missing}", file=sys.stderr)
sys.exit(1)
ptype = p.get("provider", "").lower()
if ptype == "noip":
extra = {"username", "password", "hostnames"}
elif ptype == "duckdns":
extra = {"api_token", "hostnames"}
elif ptype == "cloudflare":
extra = {"api_token", "hostnames"}
else:
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'", file=sys.stderr)
sys.exit(1)
missing = extra - set(p.keys())
if missing:
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}", file=sys.stderr)
sys.exit(1)
return data
# ===================================================================
# Helpers
# ===================================================================
def chown_to_script_dir_owner(path):
"""Chown a file to the owner of the script directory.
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer),
or as a normal user - the script directory owner is always the right target.
"""
try:
stat = SCRIPT_DIR.stat()
os.chown(path, stat.st_uid, stat.st_gid)
except OSError:
pass # non-fatal
# ===================================================================
# Logging
# ===================================================================
def setup_logging(max_kb, errors_only):
"""Clear log if oversized, then initialise logger. Must be called before log is used."""
global log
max_bytes = int(max_kb * 1024)
try:
if LOG_FILE.exists() and LOG_FILE.stat().st_size > max_bytes:
LOG_FILE.write_text("")
if not LOG_FILE.exists():
LOG_FILE.touch()
chown_to_script_dir_owner(LOG_FILE)
file_handler = logging.FileHandler(LOG_FILE)
except PermissionError:
print(f"WARNING: Cannot write to {LOG_FILE} (permission denied). "
f"Run with sudo or fix ownership: sudo chown $USER {LOG_FILE}")
file_handler = None
level = logging.ERROR if errors_only else logging.INFO
handlers = [logging.StreamHandler(sys.stdout)]
if file_handler:
handlers.insert(0, file_handler)
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=handlers,
)
log = logging.getLogger("ddns")
# ===================================================================
# Per-provider IP cache
# ===================================================================
def cache_file_for(description):
"""Return the cache file path for a given provider description."""
safe_name = description.replace(" ", "-")
return SCRIPT_DIR / f".ddns-last-ip-{safe_name}"
def get_cached_ip(description):
f = cache_file_for(description)
if f.exists():
return f.read_text().strip()
return None
def save_cached_ip(description, ip):
f = cache_file_for(description)
f.write_text(ip)
chown_to_script_dir_owner(f)
# ===================================================================
# Service rotation
# ===================================================================
def get_next_service_index(total):
"""Read last used index, increment, wrap around, return next index."""
if CACHE_SERVICE_FILE.exists():
try:
last = int(CACHE_SERVICE_FILE.read_text().strip())
except ValueError:
last = -1
else:
last = -1
return (last + 1) % total
def save_service_index(index):
CACHE_SERVICE_FILE.write_text(str(index))
chown_to_script_dir_owner(CACHE_SERVICE_FILE)
# ===================================================================
# Public IP detection
# ===================================================================
def extract_ip(body):
"""
Extract an IP address from a service response.
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.
"""
# 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
# Fall back to extracting from HTML
match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', body)
if match:
return match.group(1)
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):
"""
Start at the next service in rotation. If it fails, fall through
the remaining services in order. Saves the index of the service
that succeeded so the next run starts with the following one.
"""
total = len(services)
start = get_next_service_index(total)
ordered = [services[(start + i) % total] for i in range(total)]
for i, service in enumerate(ordered):
try:
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 {service}: {e}")
continue
log.error("Could not determine public IP from any configured service.")
sys.exit(1)
# ===================================================================
# No-IP update
# ===================================================================
def update_noip(provider, ip):
"""
No-IP HTTP update API.
Docs: https://www.noip.com/integrate/request
Uses HTTP Basic Auth. Supports comma-separated list of hostnames.
"""
username = provider["username"]
password = provider["password"]
hostnames = ",".join(provider["hostnames"])
url = f"https://dynupdate.no-ip.com/nic/update?hostname={hostnames}&myip={ip}"
password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
password_mgr.add_password(None, url, username, password)
handler = urllib.request.HTTPBasicAuthHandler(password_mgr)
opener = urllib.request.build_opener(handler)
req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"})
try:
with opener.open(req, timeout=10) as r:
return r.read().decode().strip()
except urllib.error.URLError as e:
log.error(f"Network error contacting No-IP: {e}")
return None
def interpret_noip_response(response, hostnames, ip):
"""
No-IP response codes:
good <ip> -- update successful
nochg <ip> -- IP already set to this value (no change needed)
nohost -- hostname not found in account
badauth -- invalid credentials
badagent -- client blocked
!donator -- feature requires paid account
abuse -- account blocked for abuse
911 -- server-side error, retry later
"""
if response is None:
return False
if response.startswith("good"):
log.info(f"No-IP updated successfully: {hostnames} -> {ip}")
return True
elif response.startswith("nochg"):
log.info(f"No-IP: no change needed ({hostnames} already set to {ip})")
return True
elif response == "nohost":
log.error(f"No-IP: hostname '{hostnames}' not found in account.")
elif response == "badauth":
log.error(f"No-IP: authentication failed for '{hostnames}'. Check username and password.")
elif response == "badagent":
log.error("No-IP: client blocked by No-IP.")
elif response == "!donator":
log.error("No-IP: this feature requires a paid account.")
elif response == "abuse":
log.error("No-IP: account blocked for abuse.")
elif response == "911":
log.error("No-IP: server error. Will retry on next run.")
else:
log.error(f"No-IP: unexpected response: {response}")
return False
# ===================================================================
# DuckDNS update
# ===================================================================
def update_duckdns(provider, ip):
"""
DuckDNS HTTP update API.
Docs: https://www.duckdns.org/spec.jsp
Token-based, no username/password. Subdomains are the short name only
(e.g. "myhome", not "myhome.duckdns.org"). Supports multiple subdomains
as a comma-separated list.
Returns True on success, False on failure.
"""
token = provider["api_token"]
subdomains = ",".join(h.replace(".duckdns.org", "") for h in provider["hostnames"])
description = provider["description"]
url = f"https://www.duckdns.org/update?domains={subdomains}&token={token}&ip={ip}"
try:
req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"})
with urllib.request.urlopen(req, timeout=10) as r:
response = r.read().decode().strip()
if response == "OK":
log.info(f"DuckDNS updated successfully: {subdomains} -> {ip}")
return True
else:
log.error(f"DuckDNS update failed for '{description}': response was '{response}'")
return False
except urllib.error.URLError as e:
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
# ===================================================================
def process_provider(provider, current_ip, force=False):
description = provider["description"]
if not provider.get("enabled") is True:
log.info(f"Provider '{description}' is disabled, skipping.")
return
cached_ip = get_cached_ip(description)
if not force and current_ip == cached_ip:
log.info(f"[{description}] IP unchanged ({current_ip}), skipping update.")
return
if force:
log.info(f"[{description}] Force update requested. Updating with {current_ip}...")
elif cached_ip:
log.info(f"[{description}] IP changed: {cached_ip} -> {current_ip}. Updating...")
else:
log.info(f"[{description}] No cached IP found. Updating with {current_ip}...")
ptype = provider["provider"].lower()
if ptype == "noip":
hostnames = ",".join(provider["hostnames"])
response = update_noip(provider, current_ip)
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
if success:
save_cached_ip(description, current_ip)
# ===================================================================
# Timer management
# ===================================================================
def parse_interval(interval_str):
"""
Convert interval string (e.g. 5m, 2h, 1d) to systemd OnUnitActiveSec value.
Supported units: m (minutes), h (hours), d (days).
"""
interval_str = interval_str.strip()
if interval_str.endswith("m"):
return f"{interval_str[:-1]}min"
elif interval_str.endswith("h"):
return f"{interval_str[:-1]}h"
elif interval_str.endswith("d"):
return f"{interval_str[:-1]}day"
else:
print(f"ERROR: Invalid timer_interval format: '{interval_str}'. Use e.g. 5m, 2h, 1d.")
sys.exit(1)
def get_current_timer_interval():
"""Read the current OnUnitActiveSec value from the timer file, or None if not present."""
if not TIMER_FILE.exists():
return None
for line in TIMER_FILE.read_text().splitlines():
if line.strip().startswith("OnUnitActiveSec="):
return line.strip().split("=", 1)[1]
return None
def install_timer(cfg):
interval = cfg["general"].get("timer_interval", "5m")
systemd_unit = parse_interval(interval)
script_path = Path(__file__).resolve()
current_interval = get_current_timer_interval()
if current_interval == systemd_unit:
log.info(f"Timer already set to {interval}, no update needed.")
return
service_content = f"""[Unit]
Description=DDNS update service
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 {script_path} --apply
"""
timer_content = f"""[Unit]
Description=DDNS update timer
[Timer]
OnUnitActiveSec={systemd_unit}
OnBootSec=1min
[Install]
WantedBy=timers.target
"""
SERVICE_FILE.write_text(service_content)
TIMER_FILE.write_text(timer_content)
subprocess.run(["systemctl", "daemon-reload"], check=True)
subprocess.run(["systemctl", "enable", "--now", f"{TIMER_NAME}.timer"], check=True)
if current_interval is None:
log.info(f"Timer installed: runs every {interval}.")
else:
log.info(f"Timer updated: was {current_interval}, now runs every {interval}.")
def remove_timer():
if TIMER_FILE.exists() or SERVICE_FILE.exists():
subprocess.run(
["systemctl", "disable", "--now", f"{TIMER_NAME}.timer"],
capture_output=True
)
for f in (TIMER_FILE, SERVICE_FILE):
if f.exists():
f.unlink()
print(f"Removed: {f}")
subprocess.run(["systemctl", "daemon-reload"], capture_output=True)
print("Timer removed.")
else:
print("No timer found, nothing to remove.")
# ===================================================================
# Main
# ===================================================================
def run_update(cfg, force=False, getip_only=False):
"""Perform a single DDNS update pass. Called by both timer and --start.
If force=True, bypasses the cached IP check and always updates.
If getip_only=True, prints the detected public IP and returns without updating providers."""
general = cfg["general"]
current_ip = get_public_ip(general["ip_check_services"])
if getip_only:
print(current_ip)
return
enabled = [p for p in cfg["providers"] if p.get("enabled") is True]
if not enabled:
log.error("No enabled providers found in config.")
sys.exit(1)
for provider in enabled:
process_provider(provider, current_ip, force=force)
def show_status():
"""Show status of managed timer."""
result = subprocess.run(
["systemctl", "status", f"{TIMER_NAME}.timer", "--no-pager"],
capture_output=True, text=True
)
print(result.stdout)
def main():
import argparse
parser = argparse.ArgumentParser(
description="Update DDNS provider(s) with current public IP",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" sudo python3 ddns.py --start Run update and install systemd timer\n"
" sudo python3 ddns.py --disable Stop updates and remove systemd timer\n"
" python3 ddns.py --apply Run update once (used by timer)\n"
" python3 ddns.py --force Force update regardless of cached IP\n"
" python3 ddns.py --status Show timer/service status\n"
" python3 ddns.py --getip Print current public IP and exit\n"
)
)
parser.add_argument("--start", action="store_true", help="Run update and install systemd timer")
parser.add_argument("--disable", action="store_true", help="Stop updates and remove systemd timer")
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP")
parser.add_argument("--status", action="store_true", help="Show timer/service status")
parser.add_argument("--getip", action="store_true", help="Print current public IP and exit")
args = parser.parse_args()
if not any([args.start, args.disable, args.apply, args.force, args.status, args.getip]):
parser.print_help()
return
if args.status:
show_status()
return
if args.getip:
global log
log = logging.getLogger("ddns_quiet")
log.addHandler(logging.NullHandler())
log.propagate = False
cfg = load_config()
run_update(cfg, getip_only=True)
return
cfg = load_config()
general = cfg["general"]
setup_logging(general["log_max_kb"], general["log_errors_only"])
if args.disable:
remove_timer()
return
if args.start:
run_update(cfg)
install_timer(cfg)
return
if args.apply or args.force:
run_update(cfg, force=args.force)
if __name__ == "__main__":
main()