From 1824ae70a1149ec9e8dae4df7aa6b55f9eea3237 Mon Sep 17 00:00:00 2001 From: Nik Rozman Date: Fri, 27 Mar 2026 19:31:08 +0100 Subject: [PATCH] Add tracker record support and update configuration options --- cloudflare_dyndns.py | 116 +++++++++++++++++++++++++++++++++++++++++-- config.example.ini | 9 +++- docker-compose.yml | 2 - 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/cloudflare_dyndns.py b/cloudflare_dyndns.py index 44daef1..9c44dbb 100644 --- a/cloudflare_dyndns.py +++ b/cloudflare_dyndns.py @@ -10,11 +10,17 @@ Config (config.ini by default): zone_id = [service] - ipinfo_url = https://ipinfo.io/ip ; optional - poll_seconds = 3600 ; optional + ipinfo_url = https://ipinfo.io/ip ; optional + poll_seconds = 3600 ; optional + tracker_record = _dyndns-last-ip ; TXT record name used as source of truth + ; for the last IP this app set; create it + ; manually in Cloudflare with the current A + ; record IP before first run so the app knows + ; what to look for. Environment overrides (take precedence over config file): - CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID, IPINFO_URL, POLL_SECONDS, CONFIG_PATH, LAST_IP_OVERRIDE + CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID, IPINFO_URL, POLL_SECONDS, CONFIG_PATH, + LAST_IP_OVERRIDE, LAST_IP_FILE, TRACKER_RECORD """ from __future__ import annotations @@ -35,6 +41,7 @@ IP_SOURCES = [ "https://icanhazip.com", ] DEFAULT_POLL_SECONDS = 3600 +DEFAULT_LAST_IP_FILE = "last_ip.txt" # Minimum agreeing sources needed to trust an IP result. MIN_AGREEING_SOURCES = 2 @@ -47,6 +54,22 @@ def is_valid_ipv4(ip: str) -> bool: return all(0 <= int(part) <= 255 for part in ip.split(".")) +def read_last_ip_file(path: str) -> Optional[str]: + try: + ip = open(path).read().strip() + return ip if is_valid_ipv4(ip) else None + except FileNotFoundError: + return None + + +def write_last_ip_file(path: str, ip: str) -> None: + try: + with open(path, "w") as f: + f.write(ip) + except OSError as exc: + print(f"Warning: could not write last IP file {path!r}: {exc}", file=sys.stderr) + + def load_config(path: str) -> dict: parser = configparser.ConfigParser() if not os.path.exists(path): @@ -66,6 +89,7 @@ def load_config(path: str) -> dict: "ipinfo_url": get_opt("service", "ipinfo_url"), "poll_seconds": None, "last_ip_override": get_opt("service", "last_ip_override"), + "tracker_record": get_opt("service", "tracker_record"), } poll_raw = get_opt("service", "poll_seconds") @@ -142,6 +166,52 @@ def fetch_records_with_ip( return records +def fetch_tracker_record( + session: requests.Session, + token: str, + zone_id: str, + name: str, +) -> tuple[Optional[str], Optional[str]]: + """Return (record_id, ip_value) from the TXT tracker record, or (None, None).""" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" + resp = session.get(url, headers=headers, params={"type": "TXT", "name": name}, timeout=15) + resp.raise_for_status() + data = resp.json() + if not data.get("success"): + raise RuntimeError(f"Cloudflare API error fetching tracker record: {data}") + results = data.get("result", []) + if not results: + return None, None + rec = results[0] + # TXT content is wrapped in quotes by Cloudflare. + ip = rec["content"].strip('"').strip() + return rec["id"], ip if is_valid_ipv4(ip) else None + + +def upsert_tracker_record( + session: requests.Session, + token: str, + zone_id: str, + name: str, + ip: str, + record_id: Optional[str], +) -> None: + """Create or update the TXT tracker record with the given IP.""" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + payload = {"type": "TXT", "name": name, "content": ip, "ttl": 60} + if record_id: + url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" + resp = session.put(url, headers=headers, json=payload, timeout=15) + else: + url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" + resp = session.post(url, headers=headers, json=payload, timeout=15) + resp.raise_for_status() + data = resp.json() + if not data.get("success"): + raise RuntimeError(f"Failed to upsert tracker record {name!r}: {data}") + + def update_record_ip( session: requests.Session, token: str, @@ -186,14 +256,35 @@ def main() -> None: poll_seconds = config.get("poll_seconds") or DEFAULT_POLL_SECONDS last_ip_override = os.getenv("LAST_IP_OVERRIDE") or config.get("last_ip_override") + last_ip_file = os.getenv("LAST_IP_FILE", DEFAULT_LAST_IP_FILE) + tracker_record = os.getenv("TRACKER_RECORD") or config.get("tracker_record") session = requests.Session() - last_ip: Optional[str] = last_ip_override + + # Determine starting last_ip: + # explicit override > Cloudflare TXT tracker > persisted file > None + tracker_record_id: Optional[str] = None + if last_ip_override: + last_ip: Optional[str] = last_ip_override + elif tracker_record: + try: + tracker_record_id, last_ip = fetch_tracker_record(session, token, zone_id, tracker_record) + except Exception as exc: + print(f"Warning: could not read tracker record {tracker_record!r}: {exc}", file=sys.stderr) + last_ip = read_last_ip_file(last_ip_file) + else: + last_ip = read_last_ip_file(last_ip_file) print(f"Starting Cloudflare DDNS watcher (interval={poll_seconds}s)") print(f"Primary IP endpoint: {ipinfo_url} (with {len(IP_SOURCES) - 1} fallbacks)") + if tracker_record: + print(f"Tracker TXT record: {tracker_record} (id={tracker_record_id or 'not found — will create'})") + else: + print(f"Last IP file: {last_ip_file}") if last_ip_override: print(f"Starting with overridden last_ip: {last_ip_override}") + elif last_ip: + print(f"Resuming with last_ip: {last_ip}") try: while True: @@ -206,7 +297,16 @@ def main() -> None: if last_ip is None: last_ip = current_ip - print(f"Initial IP: {current_ip}") + write_last_ip_file(last_ip_file, last_ip) + if tracker_record: + try: + upsert_tracker_record(session, token, zone_id, tracker_record, last_ip, tracker_record_id) + print(f"Initial IP: {current_ip} (tracker record set)") + except Exception as exc: + print(f"Warning: could not set tracker record: {exc}", file=sys.stderr) + print(f"Initial IP: {current_ip}") + else: + print(f"Initial IP: {current_ip}") elif current_ip != last_ip: print(f"IP changed: {last_ip} -> {current_ip}") try: @@ -223,6 +323,12 @@ def main() -> None: # Advance last_ip regardless — old IP is gone, no point # searching for it again next cycle. last_ip = current_ip + write_last_ip_file(last_ip_file, last_ip) + if tracker_record: + try: + upsert_tracker_record(session, token, zone_id, tracker_record, last_ip, tracker_record_id) + except Exception as exc: + print(f"Warning: could not update tracker record: {exc}", file=sys.stderr) except Exception as api_err: print(f"Cloudflare update failed: {api_err}", file=sys.stderr) # Do NOT advance last_ip; retry on next cycle. diff --git a/config.example.ini b/config.example.ini index 5a4111b..60d86b4 100644 --- a/config.example.ini +++ b/config.example.ini @@ -5,5 +5,12 @@ zone_id = YOUR_ZONE_ID [service] ipinfo_url = https://ipinfo.io/ip poll_seconds = 3600 -; Optionally seed last_ip to force an update on first run + +; TXT record used as source of truth for the last IP this app set. +; Create it manually in Cloudflare with your current A record IP before +; first run (Name: _dyndns-last-ip, Type: TXT, Content: x.x.x.x, TTL: 60). +; The app will update it automatically on each IP change. +tracker_record = _dyndns-last-ip + +; Optionally force a specific starting IP (overrides tracker record and last_ip.txt) ; last_ip_override = 203.0.113.10 diff --git a/docker-compose.yml b/docker-compose.yml index 99478ff..de4220c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: cloudflare-dyndns: image: python:3.11-slim