diff --git a/README.md b/README.md index fd7aff3..7428adf 100644 --- a/README.md +++ b/README.md @@ -261,23 +261,23 @@ Configure mDNS reflection with the top-level `mdns_reflection` block in `core.js ## Initial Deployment ```bash -sudo ./core.py --install # Check and install required packages -sudo ./core.py --apply # Apply VLANs, DHCP, DNS, firewall, RADIUS, mDNS, timers -sudo ./core.py --update-blocklists # Download and apply blocklists +sudo python3 core.py --install # Check and install required packages +sudo python3 core.py --apply # Apply VLANs, DHCP, DNS, firewall, RADIUS, mDNS, timers +sudo python3 core.py --update-blocklists # Download and apply blocklists ``` Optional (if DDNS is desired): ```bash -sudo ./ddns.py --start # Run an immediate IP update and install the update timer +sudo python3 ddns.py --start # Run an immediate IP update and install the update timer ``` Optional (if VPN is desired): ```bash -sudo ./vpn.py --add-peer # Add a VPN peer interactively -sudo ./vpn.py --apply # Write WireGuard config and start the interface -sudo ./core.py --apply # Run again after VPN to start dnsmasq for the VPN VLAN(s) +sudo python3 vpn.py --add-peer # Add a VPN peer interactively +sudo python3 vpn.py --apply # Write WireGuard config and start the interface +sudo python3 core.py --apply # Run again after VPN to start dnsmasq for the VPN VLAN(s) ``` After adding VPN peers, transfer `vpn-client-.conf` to the peer device by secure means, then delete it from this server. @@ -293,20 +293,20 @@ All scripts are designed to be run multiple times - re-running `--apply` replace Commands that modify system state require `sudo`. Read-only commands do not. ``` -sudo ./core.py --install # Check and interactively install required packages -sudo ./core.py --apply # Apply full config: networkd, dnsmasq, nftables, RADIUS, mDNS, timer, boot service -sudo ./core.py --apply --dry-run # Preview --apply actions without making changes -sudo ./core.py --update-blocklists # Download and merge blocklists, then --apply -sudo ./core.py --disable # Revert to network client (interactive wizard) -sudo ./core.py --disable --dry-run # Preview --disable wizard without making changes -sudo ./core.py --reset-leases # Stop dnsmasq, delete all lease files, restart (forces devices to re-acquire) -sudo ./core.py --reset-leases VLAN # Reset leases for a specific VLAN only (e.g. trusted, iot, guest) +sudo python3 core.py --install # Check and interactively install required packages +sudo python3 core.py --apply # Apply full config: networkd, dnsmasq, nftables, RADIUS, mDNS, timer, boot service +sudo python3 core.py --apply --dry-run # Preview --apply actions without making changes +sudo python3 core.py --update-blocklists # Download and merge blocklists, then --apply +sudo python3 core.py --disable # Revert to network client (interactive wizard) +sudo python3 core.py --disable --dry-run # Preview --disable wizard without making changes +sudo python3 core.py --reset-leases # Stop dnsmasq, delete all lease files, restart (forces devices to re-acquire) +sudo python3 core.py --reset-leases VLAN # Reset leases for a specific VLAN only (e.g. trusted, iot, guest) -./core.py --status # Per-VLAN dnsmasq, freeradius, avahi-daemon, timer, and boot service status -./core.py --view-configs # Active per-VLAN dnsmasq config files -./core.py --view-leases # Active DHCP leases across all VLANs with VLAN, type, and description -./core.py --view-rules # Active nftables ruleset -./core.py --view-metrics # Lifetime DNS metrics across all VLAN instances +python3 core.py --status # Per-VLAN dnsmasq, freeradius, avahi-daemon, timer, and boot service status +python3 core.py --view-configs # Active per-VLAN dnsmasq config files +python3 core.py --view-leases # Active DHCP leases across all VLANs with VLAN, type, and description +python3 core.py --view-rules # Active nftables ruleset +python3 core.py --view-metrics # Lifetime DNS metrics across all VLAN instances ``` ### vpn.py @@ -314,12 +314,12 @@ sudo ./core.py --reset-leases VLAN # Reset leases for a specific VLAN only (e All `vpn.py` commands require `sudo`. ``` -sudo ./vpn.py --add-peer # Add a VPN peer interactively -sudo ./vpn.py --manage-peers # Rename, regenerate keys, or delete a peer -sudo ./vpn.py --apply # Write WireGuard config and start/restart the interface -sudo ./vpn.py --disable # Stop WireGuard on all interfaces -sudo ./vpn.py --status # WireGuard service and interface status -sudo ./vpn.py --view-peers # Per-peer handshake times and traffic stats +sudo python3 vpn.py --add-peer # Add a VPN peer interactively +sudo python3 vpn.py --manage-peers # Rename, regenerate keys, or delete a peer +sudo python3 vpn.py --apply # Write WireGuard config and start/restart the interface +sudo python3 vpn.py --disable # Stop WireGuard on all interfaces +sudo python3 vpn.py --status # WireGuard service and interface status +sudo python3 vpn.py --view-peers # Per-peer handshake times and traffic stats ``` ### ddns.py @@ -327,12 +327,12 @@ sudo ./vpn.py --view-peers # Per-peer handshake times and traffic sta Only `--start` and `--disable` require `sudo` as they install/remove systemd timer files. All other commands run as a normal user. ``` -sudo ./ddns.py --start # Run update and install systemd timer -sudo ./ddns.py --disable # Stop updates and remove systemd timer +sudo python3 ddns.py --start # Run update and install systemd timer +sudo python3 ddns.py --disable # Stop updates and remove systemd timer -./ddns.py --apply # Run one immediate DDNS update (used by timer) -./ddns.py --force # Force update regardless of cached IP -./ddns.py --status # Timer/service status +python3 ddns.py --apply # Run one immediate DDNS update (used by timer) +python3 ddns.py --force # Force update regardless of cached IP +python3 ddns.py --status # Timer/service status ``` --- @@ -340,7 +340,7 @@ sudo ./ddns.py --disable # Stop updates and remove systemd timer ## Disabling / Uninstalling Components ```bash -sudo ./core.py --disable # Revert to network client (interactive wizard) -sudo ./vpn.py --disable # Stop WireGuard on all interfaces -sudo ./ddns.py --disable # Stop and remove DDNS timer +sudo python3 core.py --disable # Revert to network client (interactive wizard) +sudo python3 vpn.py --disable # Stop WireGuard on all interfaces +sudo python3 ddns.py --disable # Stop and remove DDNS timer ``` diff --git a/core.py b/core.py index 5942086..946032d 100644 --- a/core.py +++ b/core.py @@ -168,7 +168,7 @@ def service_warning(action, svc, stderr): msg = stderr.strip() print(f"WARNING: Failed to {action} {svc}: {msg}") if "not found" in msg.lower() or "not-found" in msg.lower(): - print(f" -> Package may not be installed. Run: sudo ./core.py --install") + print(f" -> Package may not be installed. Run: sudo python3 core.py --install") def die(msg): @@ -2334,7 +2334,7 @@ def apply_avahi(data): import shutil if not shutil.which("avahi-daemon"): print("avahi-daemon is not installed.") - print(" -> Run: sudo ./core.py --install") + print(" -> Run: sudo python3 core.py --install") return ifaces = avahi_interfaces(data) @@ -2344,7 +2344,7 @@ def apply_avahi(data): return if not AVAHI_CONF_FILE.exists(): - print(f"WARNING: {AVAHI_CONF_FILE} not found. Run: sudo ./core.py --install") + print(f"WARNING: {AVAHI_CONF_FILE} not found. Run: sudo python3 core.py --install") return content = build_avahi_conf(data) diff --git a/ddns.json b/ddns.json index 81330ae..e18dda3 100644 --- a/ddns.json +++ b/ddns.json @@ -9,6 +9,8 @@ "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", @@ -19,7 +21,7 @@ }, "providers": [ { - "description": "No-IP Main Account", + "description": "No-IP Account", "provider": "noip", "enabled": true, "username": "your-username", @@ -29,7 +31,16 @@ ] }, { - "description": "DuckDNS Main Account", + "description": "Cloudflare Account", + "provider": "cloudflare", + "enabled": true, + "api_token": "your-cloudflare-api-token", + "hostnames": [ + "yourdomain.com" + ] + }, + { + "description": "DuckDNS Account", "provider": "duckdns", "enabled": false, "token": "your-duckdns-token", diff --git a/ddns.py b/ddns.py index f22f21c..0bfe94a 100644 --- a/ddns.py +++ b/ddns.py @@ -80,6 +80,8 @@ def load_config(): extra = {"username", "password", "hostnames"} elif ptype == "duckdns": extra = {"token", "subdomains"} + elif ptype == "cloudflare": + extra = {"api_token", "hostnames"} else: print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'") sys.exit(1) @@ -181,11 +183,16 @@ def save_service_index(index): def extract_ip(body): """ Extract an IP address from a service response. - Handles both plain text responses (most services) and HTML responses - such as checkip.dyndns.org which returns: - ...Current IP Address: 1.2.3.4 + 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. """ - # Try plain text first (strip and validate) + # 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 @@ -196,6 +203,28 @@ def extract_ip(body): return 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' + Requires the 'dig' utility to be installed. + """ + hostname = spec[len("cf-dns:"):] + cmd = ["dig", "+short", "@1.1.1.1", "chaos", "txt", hostname] + 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 + except FileNotFoundError: + log.warning("'dig' command not found; cannot use cf-dns IP check service.") + except Exception: + pass + return None + + # ------------------------------------------------------------------------------ def get_public_ip(services): @@ -208,19 +237,22 @@ def get_public_ip(services): start = get_next_service_index(total) ordered = [services[(start + i) % total] for i in range(total)] - for i, url in enumerate(ordered): + for i, service in enumerate(ordered): try: - req = urllib.request.Request(url, 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) - if ip: - used_index = (start + i) % total - save_service_index(used_index) - log.info(f"Public IP retrieved from {url}: {ip}") - return ip + if service.startswith("cf-dns:"): + ip = _get_ip_via_cf_dns(service) + 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) + if ip: + used_index = (start + i) % total + save_service_index(used_index) + log.info(f"Public IP retrieved from {service}: {ip}") + return ip except Exception as e: - log.warning(f"IP check failed for {url}: {e}") + log.warning(f"IP check failed for {service}: {e}") continue log.error("Could not determine public IP from any configured service.") @@ -326,6 +358,77 @@ def update_duckdns(provider, ip): log.error(f"Network error contacting DuckDNS: {e}") return False +# ------------------------------------------------------------------------------ +# Cloudflare DNS update +# ------------------------------------------------------------------------------ + +def _cf_api_get(url, headers): + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read().decode()) + except Exception as e: + log.error(f"Cloudflare API GET error ({url}): {e}") + return None + +def _cf_get_zone_id(zone_name, headers): + data = _cf_api_get( + f"https://api.cloudflare.com/client/v4/zones?name={zone_name}", headers + ) + if data and data.get("success") and data["result"]: + return data["result"][0]["id"] + return None + +def _cf_get_record_id(zone_id, hostname, headers): + data = _cf_api_get( + f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?name={hostname}&type=A", + headers, + ) + if data and data.get("success") and data["result"]: + return data["result"][0]["id"] + return None + +def update_cloudflare(provider, ip): + """ + Cloudflare DNS update API. + Docs: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/ + Bearer-token auth. Looks up zone and record IDs dynamically, then PATCHes each A record. + """ + token = provider["api_token"] + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "ddns-update/1.0", + } + success = True + for hostname in provider["hostnames"]: + zone_name = ".".join(hostname.split(".")[-2:]) + zone_id = _cf_get_zone_id(zone_name, headers) + if not zone_id: + log.error(f"Cloudflare: zone '{zone_name}' not found in account.") + success = False + continue + record_id = _cf_get_record_id(zone_id, hostname, headers) + if not record_id: + log.error(f"Cloudflare: A record for '{hostname}' not found in zone '{zone_name}'.") + success = False + continue + url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" + payload = json.dumps({"content": ip}).encode() + req = urllib.request.Request(url, data=payload, headers=headers, method="PATCH") + try: + with urllib.request.urlopen(req, timeout=10) as r: + data = json.loads(r.read().decode()) + if data.get("success"): + log.info(f"Cloudflare updated successfully: {hostname} -> {ip}") + else: + log.error(f"Cloudflare update failed for '{hostname}': {data.get('errors')}") + success = False + except Exception as e: + log.error(f"Cloudflare API PATCH error for '{hostname}': {e}") + success = False + return success + # ------------------------------------------------------------------------------ # Process a single provider block # ------------------------------------------------------------------------------ @@ -358,6 +461,8 @@ def process_provider(provider, current_ip, force=False): success = interpret_noip_response(response, hostnames, current_ip) elif ptype == "duckdns": success = update_duckdns(provider, current_ip) + elif ptype == "cloudflare": + success = update_cloudflare(provider, current_ip) else: log.error(f"[{description}] Unknown provider type: '{ptype}'") return