Improve reliability

This commit is contained in:
2026-03-27 19:23:58 +01:00
parent 8ab85c002e
commit 5408add86b
+75 -15
View File
@@ -1,7 +1,7 @@
""" """
Simple Cloudflare dynamic DNS updater. Simple Cloudflare dynamic DNS updater.
- Pulls current IPv4 from ipinfo.io (or a fallback service). - Pulls current IPv4 from multiple sources and cross-validates.
- Every hour, checks for changes and updates any A records that had the old IP. - Every hour, checks for changes and updates any A records that had the old IP.
Config (config.ini by default): Config (config.ini by default):
@@ -15,14 +15,12 @@ Config (config.ini by default):
Environment overrides (take precedence over config file): 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
Install dependency:
pip install requests
""" """
from __future__ import annotations from __future__ import annotations
import configparser import configparser
import os import os
import re
import sys import sys
import time import time
from typing import List, Optional from typing import List, Optional
@@ -30,8 +28,23 @@ from typing import List, Optional
import requests import requests
IPINFO_DEFAULT = "https://ipinfo.io/ip" IPINFO_DEFAULT = "https://ipinfo.io/ip"
FALLBACK_URL = "https://api.ipify.org" IP_SOURCES = [
"https://ipinfo.io/ip",
"https://api.ipify.org",
"https://checkip.amazonaws.com",
"https://icanhazip.com",
]
DEFAULT_POLL_SECONDS = 3600 DEFAULT_POLL_SECONDS = 3600
# Minimum agreeing sources needed to trust an IP result.
MIN_AGREEING_SOURCES = 2
_IPV4_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
def is_valid_ipv4(ip: str) -> bool:
if not _IPV4_RE.match(ip):
return False
return all(0 <= int(part) <= 255 for part in ip.split("."))
def load_config(path: str) -> dict: def load_config(path: str) -> dict:
@@ -68,7 +81,40 @@ def load_config(path: str) -> dict:
def get_public_ip(session: requests.Session, url: str) -> str: def get_public_ip(session: requests.Session, url: str) -> str:
resp = session.get(url, timeout=10) resp = session.get(url, timeout=10)
resp.raise_for_status() resp.raise_for_status()
return resp.text.strip() ip = resp.text.strip()
if not is_valid_ipv4(ip):
raise ValueError(f"Invalid IPv4 from {url!r}: {ip!r}")
return ip
def get_reliable_ip(session: requests.Session, primary_url: str) -> Optional[str]:
"""
Query multiple IP sources and return the IP that at least MIN_AGREEING_SOURCES
agree on. Returns None if no consensus can be reached.
"""
sources = [primary_url] + [s for s in IP_SOURCES if s != primary_url]
votes: dict[str, int] = {}
for url in sources:
try:
ip = get_public_ip(session, url)
votes[ip] = votes.get(ip, 0) + 1
if votes[ip] >= MIN_AGREEING_SOURCES:
return ip
except Exception as exc:
print(f" IP source {url!r} failed: {exc}", file=sys.stderr)
if votes:
# Fall back to majority if we couldn't reach the threshold.
best_ip = max(votes, key=lambda k: votes[k])
print(
f" Warning: could not get {MIN_AGREEING_SOURCES} agreeing sources "
f"(got {votes}); using {best_ip!r} with {votes[best_ip]} vote(s).",
file=sys.stderr,
)
return best_ip
return None
def fetch_records_with_ip( def fetch_records_with_ip(
@@ -77,7 +123,6 @@ def fetch_records_with_ip(
zone_id: str, zone_id: str,
ip: str, ip: str,
) -> List[dict]: ) -> List[dict]:
# Fetch all A records matching the old IP so we can update them.
records: List[dict] = [] records: List[dict] = []
page = 1 page = 1
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
@@ -134,25 +179,30 @@ def main() -> None:
sys.exit(1) sys.exit(1)
ipinfo_url = os.getenv("IPINFO_URL") or config.get("ipinfo_url") or IPINFO_DEFAULT ipinfo_url = os.getenv("IPINFO_URL") or config.get("ipinfo_url") or IPINFO_DEFAULT
poll_seconds = os.getenv("POLL_SECONDS") poll_seconds_env = os.getenv("POLL_SECONDS")
poll_seconds = int(poll_seconds) if poll_seconds else config.get("poll_seconds", DEFAULT_POLL_SECONDS) if poll_seconds_env:
poll_seconds = int(poll_seconds_env)
else:
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_override = os.getenv("LAST_IP_OVERRIDE") or config.get("last_ip_override")
session = requests.Session() session = requests.Session()
last_ip: Optional[str] = last_ip_override last_ip: Optional[str] = last_ip_override
print(f"Starting Cloudflare DDNS watcher (interval={poll_seconds}s)") print(f"Starting Cloudflare DDNS watcher (interval={poll_seconds}s)")
print(f"Using IP endpoint: {ipinfo_url}") print(f"Primary IP endpoint: {ipinfo_url} (with {len(IP_SOURCES) - 1} fallbacks)")
if last_ip_override: if last_ip_override:
print(f"Starting with overridden last_ip: {last_ip_override}") print(f"Starting with overridden last_ip: {last_ip_override}")
try: try:
while True: while True:
try: try:
current_ip = get_public_ip(session, ipinfo_url) current_ip = get_reliable_ip(session, ipinfo_url)
except Exception: if current_ip is None:
# Fallback if primary service fails. print("Could not determine public IP from any source; skipping this cycle.", file=sys.stderr)
current_ip = get_public_ip(session, FALLBACK_URL) time.sleep(poll_seconds)
continue
if last_ip is None: if last_ip is None:
last_ip = current_ip last_ip = current_ip
@@ -162,17 +212,27 @@ def main() -> None:
try: try:
records = fetch_records_with_ip(session, token, zone_id, last_ip) records = fetch_records_with_ip(session, token, zone_id, last_ip)
if not records: if not records:
print("No records matched old IP; nothing to update.") print(
f"No A records matched old IP {last_ip!r}; "
"they may have already been updated externally."
)
else: else:
for rec in records: for rec in records:
update_record_ip(session, token, zone_id, rec, current_ip) update_record_ip(session, token, zone_id, rec, current_ip)
print(f"Updated {rec['name']} to {current_ip}") print(f"Updated {rec['name']} to {current_ip}")
# Advance last_ip regardless — old IP is gone, no point
# searching for it again next cycle.
last_ip = current_ip last_ip = current_ip
except Exception as api_err: except Exception as api_err:
print(f"Cloudflare update failed: {api_err}", file=sys.stderr) print(f"Cloudflare update failed: {api_err}", file=sys.stderr)
# Do NOT advance last_ip; retry on next cycle.
else: else:
print(f"No change. Current IP: {current_ip}") print(f"No change. Current IP: {current_ip}")
except Exception as exc:
# Catch-all so a transient error never kills the process.
print(f"Unexpected error in poll cycle: {exc}", file=sys.stderr)
time.sleep(poll_seconds) time.sleep(poll_seconds)
except KeyboardInterrupt: except KeyboardInterrupt:
print("Stopping watcher.") print("Stopping watcher.")