Development
This commit is contained in:
parent
bccb260ed0
commit
30fbcdb64c
3 changed files with 61 additions and 108 deletions
|
|
@ -687,21 +687,6 @@
|
||||||
"general": {
|
"general": {
|
||||||
"log_max_kb": 1024,
|
"log_max_kb": 1024,
|
||||||
"log_errors_only": false,
|
"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"
|
"timer_interval": "10m"
|
||||||
},
|
},
|
||||||
"providers": [
|
"providers": [
|
||||||
|
|
@ -737,6 +722,24 @@
|
||||||
"yourothersubdomain.duckdns.org"
|
"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"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -51,14 +51,25 @@ def load_config():
|
||||||
data = json.load(f).get("ddns", {})
|
data = json.load(f).get("ddns", {})
|
||||||
|
|
||||||
# Validate general block
|
# 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())
|
missing = required_general - set(data.get("general", {}).keys())
|
||||||
if missing:
|
if missing:
|
||||||
print(f"ERROR: Missing keys in ddns.general block: {missing}", file=sys.stderr)
|
print(f"ERROR: Missing keys in ddns.general block: {missing}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if not data["general"]["ip_check_services"]:
|
services = data.get("ip_check_services", [])
|
||||||
print("ERROR: ddns.ip_check_services list is empty.", file=sys.stderr)
|
if not services:
|
||||||
|
print("ERROR: ddns.general.ip_check_services is empty.", file=sys.stderr)
|
||||||
sys.exit(1)
|
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
|
# Validate providers block
|
||||||
if not data.get("providers"):
|
if not data.get("providers"):
|
||||||
|
|
@ -176,46 +187,43 @@ def save_service_index(index):
|
||||||
# Public IP detection
|
# 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():
|
for line in body.splitlines():
|
||||||
if line.startswith("ip="):
|
if line.startswith("ip="):
|
||||||
candidate = line[3:].strip()
|
candidate = line[3:].strip()
|
||||||
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', candidate):
|
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', candidate):
|
||||||
return candidate
|
return candidate
|
||||||
# Try plain text (strip and validate)
|
|
||||||
plain = body.strip()
|
plain = body.strip()
|
||||||
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', plain):
|
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', plain):
|
||||||
return 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)
|
match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', body)
|
||||||
if match:
|
return match.group(1) if match else None
|
||||||
return match.group(1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_ip_via_cf_dns(spec):
|
def _get_ip_via_http(spec):
|
||||||
"""Query Cloudflare's myip.cloudflare via DNS TXT (chaos class) for the caller's IP.
|
"""Fetch public IP from an HTTP endpoint. spec: {"type": "http", "url": "..."}"""
|
||||||
spec format: 'cf-dns:<hostname>' e.g. 'cf-dns:myip.cloudflare'
|
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": "<dig args>"}
|
||||||
Requires the 'dig' utility to be installed.
|
Requires the 'dig' utility to be installed.
|
||||||
"""
|
"""
|
||||||
hostname = spec[len("cf-dns:"):]
|
cmd = ["dig", "+short"] + spec["url"].split()
|
||||||
cmd = ["dig", "+short", "@1.1.1.1", "chaos", "txt", hostname]
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return None
|
return None
|
||||||
# TXT records come back quoted: "203.0.113.50"
|
match = re.search(r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b', result.stdout)
|
||||||
ip = result.stdout.strip().strip('"').split()[0]
|
if match:
|
||||||
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', ip):
|
return match.group(1)
|
||||||
return ip
|
|
||||||
except FileNotFoundError:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
@ -233,22 +241,20 @@ def get_public_ip(services):
|
||||||
start = get_next_service_index(total)
|
start = get_next_service_index(total)
|
||||||
ordered = [services[(start + i) % total] for i in range(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:
|
try:
|
||||||
if service.startswith("cf-dns:"):
|
if stype == "dig":
|
||||||
ip = _get_ip_via_cf_dns(service)
|
ip = _get_ip_via_dig(spec)
|
||||||
else:
|
else:
|
||||||
req = urllib.request.Request(service, headers={"User-Agent": "ddns-update/1.0"})
|
ip = _get_ip_via_http(spec)
|
||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
|
||||||
body = r.read().decode().strip()
|
|
||||||
ip = extract_ip(body)
|
|
||||||
if ip:
|
if ip:
|
||||||
used_index = (start + i) % total
|
save_service_index((start + i) % total)
|
||||||
save_service_index(used_index)
|
log.info(f"Public IP retrieved from {label}: {ip}")
|
||||||
log.info(f"Public IP retrieved from {service}: {ip}")
|
|
||||||
return ip
|
return ip
|
||||||
except Exception as e:
|
except Exception as ex:
|
||||||
log.warning(f"IP check failed for {service}: {e}")
|
log.warning(f"IP check failed for {label}: {ex}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
log.error("Could not determine public IP from any configured service.")
|
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 force=True, bypasses the cached IP check and always updates.
|
||||||
If getip_only=True, prints the detected public IP and returns without updating providers."""
|
If getip_only=True, prints the detected public IP and returns without updating providers."""
|
||||||
general = cfg["general"]
|
general = cfg["general"]
|
||||||
current_ip = get_public_ip(general["ip_check_services"])
|
current_ip = get_public_ip(cfg["ip_check_services"])
|
||||||
|
|
||||||
if getip_only:
|
if getip_only:
|
||||||
print(current_ip)
|
print(current_ip)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue