Added Cloudflare service to DDNS
This commit is contained in:
parent
82f3058577
commit
df09e99888
4 changed files with 170 additions and 54 deletions
68
README.md
68
README.md
|
|
@ -261,23 +261,23 @@ Configure mDNS reflection with the top-level `mdns_reflection` block in `core.js
|
||||||
## Initial Deployment
|
## Initial Deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./core.py --install # Check and install required packages
|
sudo python3 core.py --install # Check and install required packages
|
||||||
sudo ./core.py --apply # Apply VLANs, DHCP, DNS, firewall, RADIUS, mDNS, timers
|
sudo python3 core.py --apply # Apply VLANs, DHCP, DNS, firewall, RADIUS, mDNS, timers
|
||||||
sudo ./core.py --update-blocklists # Download and apply blocklists
|
sudo python3 core.py --update-blocklists # Download and apply blocklists
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional (if DDNS is desired):
|
Optional (if DDNS is desired):
|
||||||
|
|
||||||
```bash
|
```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):
|
Optional (if VPN is desired):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./vpn.py --add-peer # Add a VPN peer interactively
|
sudo python3 vpn.py --add-peer # Add a VPN peer interactively
|
||||||
sudo ./vpn.py --apply # Write WireGuard config and start the interface
|
sudo python3 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 core.py --apply # Run again after VPN to start dnsmasq for the VPN VLAN(s)
|
||||||
```
|
```
|
||||||
|
|
||||||
After adding VPN peers, transfer `vpn-client-<n>.conf` to the peer device by secure means, then delete it from this server.
|
After adding VPN peers, transfer `vpn-client-<n>.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.
|
Commands that modify system state require `sudo`. Read-only commands do not.
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo ./core.py --install # Check and interactively install required packages
|
sudo python3 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 python3 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 python3 core.py --apply --dry-run # Preview --apply actions without making changes
|
||||||
sudo ./core.py --update-blocklists # Download and merge blocklists, then --apply
|
sudo python3 core.py --update-blocklists # Download and merge blocklists, then --apply
|
||||||
sudo ./core.py --disable # Revert to network client (interactive wizard)
|
sudo python3 core.py --disable # Revert to network client (interactive wizard)
|
||||||
sudo ./core.py --disable --dry-run # Preview --disable wizard without making changes
|
sudo python3 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 python3 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 --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
|
python3 core.py --status # Per-VLAN dnsmasq, freeradius, avahi-daemon, timer, and boot service status
|
||||||
./core.py --view-configs # Active per-VLAN dnsmasq config files
|
python3 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
|
python3 core.py --view-leases # Active DHCP leases across all VLANs with VLAN, type, and description
|
||||||
./core.py --view-rules # Active nftables ruleset
|
python3 core.py --view-rules # Active nftables ruleset
|
||||||
./core.py --view-metrics # Lifetime DNS metrics across all VLAN instances
|
python3 core.py --view-metrics # Lifetime DNS metrics across all VLAN instances
|
||||||
```
|
```
|
||||||
|
|
||||||
### vpn.py
|
### 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`.
|
All `vpn.py` commands require `sudo`.
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo ./vpn.py --add-peer # Add a VPN peer interactively
|
sudo python3 vpn.py --add-peer # Add a VPN peer interactively
|
||||||
sudo ./vpn.py --manage-peers # Rename, regenerate keys, or delete a peer
|
sudo python3 vpn.py --manage-peers # Rename, regenerate keys, or delete a peer
|
||||||
sudo ./vpn.py --apply # Write WireGuard config and start/restart the interface
|
sudo python3 vpn.py --apply # Write WireGuard config and start/restart the interface
|
||||||
sudo ./vpn.py --disable # Stop WireGuard on all interfaces
|
sudo python3 vpn.py --disable # Stop WireGuard on all interfaces
|
||||||
sudo ./vpn.py --status # WireGuard service and interface status
|
sudo python3 vpn.py --status # WireGuard service and interface status
|
||||||
sudo ./vpn.py --view-peers # Per-peer handshake times and traffic stats
|
sudo python3 vpn.py --view-peers # Per-peer handshake times and traffic stats
|
||||||
```
|
```
|
||||||
|
|
||||||
### ddns.py
|
### 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.
|
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 python3 ddns.py --start # Run update and install systemd timer
|
||||||
sudo ./ddns.py --disable # Stop updates and remove systemd timer
|
sudo python3 ddns.py --disable # Stop updates and remove systemd timer
|
||||||
|
|
||||||
./ddns.py --apply # Run one immediate DDNS update (used by timer)
|
python3 ddns.py --apply # Run one immediate DDNS update (used by timer)
|
||||||
./ddns.py --force # Force update regardless of cached IP
|
python3 ddns.py --force # Force update regardless of cached IP
|
||||||
./ddns.py --status # Timer/service status
|
python3 ddns.py --status # Timer/service status
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -340,7 +340,7 @@ sudo ./ddns.py --disable # Stop updates and remove systemd timer
|
||||||
## Disabling / Uninstalling Components
|
## Disabling / Uninstalling Components
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./core.py --disable # Revert to network client (interactive wizard)
|
sudo python3 core.py --disable # Revert to network client (interactive wizard)
|
||||||
sudo ./vpn.py --disable # Stop WireGuard on all interfaces
|
sudo python3 vpn.py --disable # Stop WireGuard on all interfaces
|
||||||
sudo ./ddns.py --disable # Stop and remove DDNS timer
|
sudo python3 ddns.py --disable # Stop and remove DDNS timer
|
||||||
```
|
```
|
||||||
|
|
|
||||||
6
core.py
6
core.py
|
|
@ -168,7 +168,7 @@ def service_warning(action, svc, stderr):
|
||||||
msg = stderr.strip()
|
msg = stderr.strip()
|
||||||
print(f"WARNING: Failed to {action} {svc}: {msg}")
|
print(f"WARNING: Failed to {action} {svc}: {msg}")
|
||||||
if "not found" in msg.lower() or "not-found" in msg.lower():
|
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):
|
def die(msg):
|
||||||
|
|
@ -2334,7 +2334,7 @@ def apply_avahi(data):
|
||||||
import shutil
|
import shutil
|
||||||
if not shutil.which("avahi-daemon"):
|
if not shutil.which("avahi-daemon"):
|
||||||
print("avahi-daemon is not installed.")
|
print("avahi-daemon is not installed.")
|
||||||
print(" -> Run: sudo ./core.py --install")
|
print(" -> Run: sudo python3 core.py --install")
|
||||||
return
|
return
|
||||||
|
|
||||||
ifaces = avahi_interfaces(data)
|
ifaces = avahi_interfaces(data)
|
||||||
|
|
@ -2344,7 +2344,7 @@ def apply_avahi(data):
|
||||||
return
|
return
|
||||||
|
|
||||||
if not AVAHI_CONF_FILE.exists():
|
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
|
return
|
||||||
|
|
||||||
content = build_avahi_conf(data)
|
content = build_avahi_conf(data)
|
||||||
|
|
|
||||||
15
ddns.json
15
ddns.json
|
|
@ -9,6 +9,8 @@
|
||||||
"https://api4.my-ip.io/ip",
|
"https://api4.my-ip.io/ip",
|
||||||
"https://ipv4.icanhazip.com",
|
"https://ipv4.icanhazip.com",
|
||||||
"https://checkip.amazonaws.com",
|
"https://checkip.amazonaws.com",
|
||||||
|
"https://1.1.1.1/cdn-cgi/trace",
|
||||||
|
"cf-dns:myip.cloudflare",
|
||||||
"https://ipinfo.io/ip",
|
"https://ipinfo.io/ip",
|
||||||
"https://ipecho.net/plain",
|
"https://ipecho.net/plain",
|
||||||
"https://ident.me",
|
"https://ident.me",
|
||||||
|
|
@ -19,7 +21,7 @@
|
||||||
},
|
},
|
||||||
"providers": [
|
"providers": [
|
||||||
{
|
{
|
||||||
"description": "No-IP Main Account",
|
"description": "No-IP Account",
|
||||||
"provider": "noip",
|
"provider": "noip",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"username": "your-username",
|
"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",
|
"provider": "duckdns",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "your-duckdns-token",
|
"token": "your-duckdns-token",
|
||||||
|
|
|
||||||
135
ddns.py
135
ddns.py
|
|
@ -80,6 +80,8 @@ def load_config():
|
||||||
extra = {"username", "password", "hostnames"}
|
extra = {"username", "password", "hostnames"}
|
||||||
elif ptype == "duckdns":
|
elif ptype == "duckdns":
|
||||||
extra = {"token", "subdomains"}
|
extra = {"token", "subdomains"}
|
||||||
|
elif ptype == "cloudflare":
|
||||||
|
extra = {"api_token", "hostnames"}
|
||||||
else:
|
else:
|
||||||
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'")
|
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
@ -181,11 +183,16 @@ def save_service_index(index):
|
||||||
def extract_ip(body):
|
def extract_ip(body):
|
||||||
"""
|
"""
|
||||||
Extract an IP address from a service response.
|
Extract an IP address from a service response.
|
||||||
Handles both plain text responses (most services) and HTML responses
|
Handles plain text, key=value format (e.g. Cloudflare /cdn-cgi/trace where
|
||||||
such as checkip.dyndns.org which returns:
|
the ip= line is the caller's IP while h= is the server's IP), and HTML.
|
||||||
<html>...<body>Current IP Address: 1.2.3.4</body></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()
|
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
|
||||||
|
|
@ -196,6 +203,28 @@ def extract_ip(body):
|
||||||
return None
|
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:<hostname>' 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):
|
def get_public_ip(services):
|
||||||
|
|
@ -208,19 +237,22 @@ 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, url in enumerate(ordered):
|
for i, service in enumerate(ordered):
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"})
|
if service.startswith("cf-dns:"):
|
||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
ip = _get_ip_via_cf_dns(service)
|
||||||
body = r.read().decode().strip()
|
else:
|
||||||
ip = extract_ip(body)
|
req = urllib.request.Request(service, headers={"User-Agent": "ddns-update/1.0"})
|
||||||
if ip:
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
used_index = (start + i) % total
|
body = r.read().decode().strip()
|
||||||
save_service_index(used_index)
|
ip = extract_ip(body)
|
||||||
log.info(f"Public IP retrieved from {url}: {ip}")
|
if ip:
|
||||||
return 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:
|
except Exception as e:
|
||||||
log.warning(f"IP check failed for {url}: {e}")
|
log.warning(f"IP check failed for {service}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
log.error("Could not determine public IP from any configured service.")
|
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}")
|
log.error(f"Network error contacting DuckDNS: {e}")
|
||||||
return False
|
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
|
# 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)
|
success = interpret_noip_response(response, hostnames, current_ip)
|
||||||
elif ptype == "duckdns":
|
elif ptype == "duckdns":
|
||||||
success = update_duckdns(provider, current_ip)
|
success = update_duckdns(provider, current_ip)
|
||||||
|
elif ptype == "cloudflare":
|
||||||
|
success = update_cloudflare(provider, current_ip)
|
||||||
else:
|
else:
|
||||||
log.error(f"[{description}] Unknown provider type: '{ptype}'")
|
log.error(f"[{description}] Unknown provider type: '{ptype}'")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue