Add tracker record support and update configuration options
This commit is contained in:
@@ -10,11 +10,17 @@ Config (config.ini by default):
|
||||
zone_id = <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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
cloudflare-dyndns:
|
||||
image: python:3.11-slim
|
||||
|
||||
Reference in New Issue
Block a user