From 30fbcdb64c8773711bff9f72b8b0dc9583672df6 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Sat, 23 May 2026 23:48:11 -0400 Subject: [PATCH] Development --- routlin/core.json | 33 ++++++++++--------- routlin/ddns.json | 56 --------------------------------- routlin/ddns.py | 80 +++++++++++++++++++++++++---------------------- 3 files changed, 61 insertions(+), 108 deletions(-) delete mode 100644 routlin/ddns.json diff --git a/routlin/core.json b/routlin/core.json index 627164c..80d5ab3 100644 --- a/routlin/core.json +++ b/routlin/core.json @@ -687,21 +687,6 @@ "general": { "log_max_kb": 1024, "log_errors_only": false, - "ip_check_services": [ - "https://api.ipify.org", - "https://ifconfig.me/ip", - "https://icanhazip.com", - "https://api4.my-ip.io/ip", - "https://ipv4.icanhazip.com", - "https://checkip.amazonaws.com", - "https://1.1.1.1/cdn-cgi/trace", - "cf-dns:myip.cloudflare", - "https://ipinfo.io/ip", - "https://ipecho.net/plain", - "https://ident.me", - "https://myip.dnsomatic.com", - "https://wtfismyip.com/text" - ], "timer_interval": "10m" }, "providers": [ @@ -737,6 +722,24 @@ "yourothersubdomain.duckdns.org" ] } + ], + "ip_check_services": [ + {"type": "http", "url": "https://api.ipify.org"}, + {"type": "http", "url": "https://ifconfig.me/ip"}, + {"type": "http", "url": "https://icanhazip.com"}, + {"type": "http", "url": "https://api4.my-ip.io/ip"}, + {"type": "http", "url": "https://ipv4.icanhazip.com"}, + {"type": "http", "url": "https://checkip.amazonaws.com"}, + {"type": "http", "url": "https://1.1.1.1/cdn-cgi/trace"}, + {"type": "http", "url": "https://ipinfo.io/ip"}, + {"type": "http", "url": "https://ipecho.net/plain"}, + {"type": "http", "url": "https://ident.me"}, + {"type": "http", "url": "https://myip.dnsomatic.com"}, + {"type": "http", "url": "https://wtfismyip.com/text"}, + {"type": "dig", "url": "@1.1.1.1 ch txt whoami.cloudflare"}, + {"type": "dig", "url": "whoami.akamai.net @ns1-1.akamaitech.net"}, + {"type": "dig", "url": "-4 TXT o-o.myaddr.l.google.com @ns1.google.com"}, + {"type": "dig", "url": "-4 @ns3.cloudflare.com whoami.cloudflare.com txt"} ] } } \ No newline at end of file diff --git a/routlin/ddns.json b/routlin/ddns.json deleted file mode 100644 index c2f3946..0000000 --- a/routlin/ddns.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "general": { - "log_max_kb": 1024, - "log_errors_only": false, - "ip_check_services": [ - "https://api.ipify.org", - "https://ifconfig.me/ip", - "https://icanhazip.com", - "https://api4.my-ip.io/ip", - "https://ipv4.icanhazip.com", - "https://checkip.amazonaws.com", - "https://1.1.1.1/cdn-cgi/trace", - "cf-dns:myip.cloudflare", - "https://ipinfo.io/ip", - "https://ipecho.net/plain", - "https://ident.me", - "https://myip.dnsomatic.com", - "https://wtfismyip.com/text" - ], - "timer_interval": "10m" - }, - "providers": [ - { - "description": "No-IP Account", - "provider": "noip", - "enabled": true, - "username": "your-username", - "password": "your-password", - "hostnames": [ - "yoursubdomain.ddns.net", - "yourothersubdomain.ddns.net" - ] - }, - { - "description": "Cloudflare Account", - "provider": "cloudflare", - "enabled": true, - "api_token": "your-cloudflare-api-token", - "hostnames": [ - "yourdomain.com", - "yoursubdomain.yourdomain.com", - "yourothersubdomain.yourdomain.com" - ] - }, - { - "description": "DuckDNS Account", - "provider": "duckdns", - "enabled": false, - "api_token": "your-duckdns-api-token", - "hostnames": [ - "yoursubdomain.duckdns.org", - "yourothersubdomain.duckdns.org" - ] - } - ] -} diff --git a/routlin/ddns.py b/routlin/ddns.py index c399a9c..8fcc60a 100644 --- a/routlin/ddns.py +++ b/routlin/ddns.py @@ -51,14 +51,25 @@ def load_config(): data = json.load(f).get("ddns", {}) # Validate general block - required_general = {"log_max_kb", "log_errors_only", "ip_check_services"} + required_general = {"log_max_kb", "log_errors_only"} missing = required_general - set(data.get("general", {}).keys()) if missing: print(f"ERROR: Missing keys in ddns.general block: {missing}", file=sys.stderr) sys.exit(1) - if not data["general"]["ip_check_services"]: - print("ERROR: ddns.ip_check_services list is empty.", file=sys.stderr) + services = data.get("ip_check_services", []) + if not services: + print("ERROR: ddns.general.ip_check_services is empty.", file=sys.stderr) sys.exit(1) + for svc in services: + if not isinstance(svc, dict) or "type" not in svc: + print(f"ERROR: ip_check_services entry missing 'type': {svc}", file=sys.stderr) + sys.exit(1) + if svc["type"] == "http" and "url" not in svc: + print(f"ERROR: ip_check_services 'http' entry missing 'url': {svc}", file=sys.stderr) + sys.exit(1) + if svc["type"] == "dig" and "url" not in svc: + print(f"ERROR: ip_check_services 'dig' entry missing 'url': {svc}", file=sys.stderr) + sys.exit(1) # Validate providers block if not data.get("providers"): @@ -176,46 +187,43 @@ def save_service_index(index): # Public IP detection # =================================================================== -def extract_ip(body): +def _extract_ip(body): + """Extract an IPv4 address from an HTTP response body. + Handles plain text, key=value (e.g. Cloudflare /cdn-cgi/trace), and HTML. """ - 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 + return match.group(1) if match else 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:' e.g. 'cf-dns:myip.cloudflare' +def _get_ip_via_http(spec): + """Fetch public IP from an HTTP endpoint. spec: {"type": "http", "url": "..."}""" + req = urllib.request.Request(spec["url"], headers={"User-Agent": "ddns-update/1.0"}) + with urllib.request.urlopen(req, timeout=10) as r: + return _extract_ip(r.read().decode().strip()) + + +def _get_ip_via_dig(spec): + """Query public IP via dig. spec: {"type": "dig", "command": ""} Requires the 'dig' utility to be installed. """ - hostname = spec[len("cf-dns:"):] - cmd = ["dig", "+short", "@1.1.1.1", "chaos", "txt", hostname] + cmd = ["dig", "+short"] + spec["url"].split() 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 + match = re.search(r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b', result.stdout) + if match: + return match.group(1) except FileNotFoundError: - log.warning("'dig' command not found; cannot use cf-dns IP check service.") + log.warning("'dig' not found; cannot use dig IP check service.") except Exception: pass return None @@ -233,22 +241,20 @@ def get_public_ip(services): start = get_next_service_index(total) ordered = [services[(start + i) % total] for i in range(total)] - for i, service in enumerate(ordered): + for i, spec in enumerate(ordered): + stype = spec.get("type", "http") + label = spec.get("url", "?") try: - if service.startswith("cf-dns:"): - ip = _get_ip_via_cf_dns(service) + if stype == "dig": + ip = _get_ip_via_dig(spec) 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) + ip = _get_ip_via_http(spec) if ip: - used_index = (start + i) % total - save_service_index(used_index) - log.info(f"Public IP retrieved from {service}: {ip}") + save_service_index((start + i) % total) + log.info(f"Public IP retrieved from {label}: {ip}") return ip - except Exception as e: - log.warning(f"IP check failed for {service}: {e}") + except Exception as ex: + log.warning(f"IP check failed for {label}: {ex}") continue log.error("Could not determine public IP from any configured service.") @@ -476,7 +482,7 @@ def run_update(cfg, force=False, getip_only=False): 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"]) + current_ip = get_public_ip(cfg["ip_check_services"]) if getip_only: print(current_ip)