* [PATCH] ddns: add Cloudflare (v4) provider using API token
@ 2025-08-27 7:41 Chris Anton
2025-08-29 9:51 ` Michael Tremer
0 siblings, 1 reply; 2+ messages in thread
From: Chris Anton @ 2025-08-27 7:41 UTC (permalink / raw)
To: development
From 563f089d0820bd61ad4aecac248d5cc1f2adfc81 Mon Sep 17 00:00:00 2001
From: faithinchaos21 <45313722+faithinchaos21@users.noreply.github.com>
Date: Wed, 27 Aug 2025 01:22:46 -0500
Subject: [PATCH] ddns: add Cloudflare (v4) provider using API token
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This adds a provider “cloudflare.com-v4” that updates an A record
via Cloudflare’s v4 API using a Bearer token. The token is accepted
from either ‘token’ or legacy ‘password’ for UI compatibility.
Tested on IPFire 2.29 / Core 196:
- no-op if A already matches WAN IP
- successful update when WAN IP changes
- logs include CFv4 breadcrumbs for troubleshooting
Signed-off-by: Chris Anton <chris.v.anton@gmail.com>
---
src/ddns/providers.py | 121 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 121 insertions(+)
diff --git a/src/ddns/providers.py b/src/ddns/providers.py
index 59f9665..df0f3a9 100644
--- a/src/ddns/providers.py
+++ b/src/ddns/providers.py
@@ -341,6 +341,127 @@ def have_address(self, proto):
return False
+class DDNSProviderCloudflareV4(DDNSProvider):
+ """
+ Cloudflare v4 API using a Bearer Token.
+ Put the API Token in the 'token' OR 'password' field of the DDNS entry.
+ Optional in ddns.conf:
+ proxied = false|true (default false; keep false for WireGuard)
+ ttl = 1|60|120... (default 1 = 'automatic')
+ """
+ handle = "cloudflare.com-v4"
+ name = "Cloudflare (v4)"
+ website = "https://www.cloudflare.com/"
+ protocols = ("ipv4",)
+ supports_token_auth = True
+ holdoff_failure_days = 0
+
+ def _bool(self, key, default=False):
+ v = str(self.get(key, default)).strip().lower()
+ return v in ("1", "true", "yes", "on")
+
+ def update(self):
+ import json, urllib.request, urllib.error
+
+ tok = self.get("token") or self.get("password")
+ if not tok:
+ raise DDNSConfigurationError("API Token (password/token)
is missing.")
+
+ proxied = self._bool("proxied", False)
+ try:
+ ttl = int(self.get("ttl", 1))
+ except Exception:
+ ttl = 1
+
+ headers = {
+ "Authorization": "Bearer {0}".format(tok),
+ "Content-Type": "application/json",
+ "User-Agent": "IPFireDDNSUpdater/CFv4",
+ }
+
+ # --- find zone ---
+ parts = self.hostname.split(".")
+ if len(parts) < 2:
+ raise DDNSRequestError("Hostname '{0}' is not a valid
domain.".format(self.hostname))
+
+ zone_id = None
+ zone_name = None
+ for i in range(len(parts) - 1):
+ candidate = ".".join(parts[i:])
+ url =
f"https://api.cloudflare.com/client/v4/zones?name={candidate}"
+ try:
+ req = urllib.request.Request(url, headers=headers,
method="GET")
+ with urllib.request.urlopen(req, timeout=20) as r:
+ data = json.loads(r.read().decode())
+ except Exception as e:
+ raise DDNSUpdateError(f"Failed to query Cloudflare
zones API: {e}")
+
+ if data.get("success") and data.get("result"):
+ zone_id = data["result"][0]["id"]
+ zone_name = candidate
+ break
+
+ if not zone_id:
+ raise DDNSRequestError(f"Could not find a Cloudflare Zone
for '{self.hostname}'.")
+
+ logger.info("CFv4: zone=%s id=%s", zone_name, zone_id)
+
+ # --- get record ---
+ rec_url =
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type=A&name={self.hostname}"
+ try:
+ req = urllib.request.Request(rec_url, headers=headers,
method="GET")
+ with urllib.request.urlopen(req, timeout=20) as r:
+ rec_data = json.loads(r.read().decode())
+ except Exception as e:
+ raise DDNSUpdateError(f"Failed to query Cloudflare DNS
records API: {e}")
+
+ if not rec_data.get("success"):
+ errs = rec_data.get("errors") or []
+ if any("Authentication error" in (e.get("message", "") or
"") for e in errs):
+ raise DDNSAuthenticationError("Invalid API Token.")
+ raise DDNSUpdateError(f"Cloudflare API error finding
record: {errs}")
+
+ results = rec_data.get("result") or []
+ if not results:
+ raise DDNSRequestError(f"No A record found for
'{self.hostname}' in zone '{zone_name}'.")
+
+ record_id = results[0]["id"]
+ stored_ip = results[0]["content"]
+ logger.info("CFv4: record_id=%s stored_ip=%s", record_id, stored_ip)
+
+ # --- compare IPs ---
+ current_ip = self.get_address("ipv4")
+ logger.info("CFv4: current_ip=%s vs stored_ip=%s",
current_ip, stored_ip)
+ if current_ip == stored_ip:
+ logger.info("CFv4: no update needed")
+ return
+
+ # --- update ---
+ upd_url =
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}"
+ payload = {
+ "type": "A",
+ "name": self.hostname,
+ "content": current_ip,
+ "ttl": ttl,
+ "proxied": proxied,
+ }
+ logger.info("CFv4: updating %s -> %s (proxied=%s ttl=%s)",
self.hostname, current_ip, proxied, ttl)
+
+ try:
+ req = urllib.request.Request(
+ upd_url, data=json.dumps(payload).encode(),
headers=headers, method="PUT"
+ )
+ with urllib.request.urlopen(req, timeout=20) as r:
+ upd = json.loads(r.read().decode())
+ except Exception as e:
+ raise DDNSUpdateError(f"Failed to send update request to
Cloudflare: {e}")
+
+ if not upd.get("success"):
+ raise DDNSUpdateError(f"Cloudflare API error on update:
{upd.get('errors')}")
+
+ logger.info("CFv4: update ok for %s -> %s", self.hostname, current_ip)
+ return
+
class DDNSProtocolDynDNS2(object):
"""
^ permalink raw reply [flat|nested] 2+ messages in thread
* Re: [PATCH] ddns: add Cloudflare (v4) provider using API token
2025-08-27 7:41 [PATCH] ddns: add Cloudflare (v4) provider using API token Chris Anton
@ 2025-08-29 9:51 ` Michael Tremer
0 siblings, 0 replies; 2+ messages in thread
From: Michael Tremer @ 2025-08-29 9:51 UTC (permalink / raw)
To: Chris Anton; +Cc: development
This has been double-submitted here: https://lists.ipfire.org/ddns/B2767E75-A890-4B50-9E15-81E968177872@ipfire.org/T/#t
> On 27 Aug 2025, at 08:41, Chris Anton <chris.v.anton@gmail.com> wrote:
>
> From 563f089d0820bd61ad4aecac248d5cc1f2adfc81 Mon Sep 17 00:00:00 2001
> From: faithinchaos21 <45313722+faithinchaos21@users.noreply.github.com>
> Date: Wed, 27 Aug 2025 01:22:46 -0500
> Subject: [PATCH] ddns: add Cloudflare (v4) provider using API token
> MIME-Version: 1.0
> Content-Type: text/plain; charset=UTF-8
> Content-Transfer-Encoding: 8bit
>
> This adds a provider “cloudflare.com-v4” that updates an A record
> via Cloudflare’s v4 API using a Bearer token. The token is accepted
> from either ‘token’ or legacy ‘password’ for UI compatibility.
>
> Tested on IPFire 2.29 / Core 196:
> - no-op if A already matches WAN IP
> - successful update when WAN IP changes
> - logs include CFv4 breadcrumbs for troubleshooting
>
> Signed-off-by: Chris Anton <chris.v.anton@gmail.com>
> ---
> src/ddns/providers.py | 121 ++++++++++++++++++++++++++++++++++++++++++
> 1 file changed, 121 insertions(+)
>
> diff --git a/src/ddns/providers.py b/src/ddns/providers.py
> index 59f9665..df0f3a9 100644
> --- a/src/ddns/providers.py
> +++ b/src/ddns/providers.py
> @@ -341,6 +341,127 @@ def have_address(self, proto):
>
> return False
>
> +class DDNSProviderCloudflareV4(DDNSProvider):
> + """
> + Cloudflare v4 API using a Bearer Token.
> + Put the API Token in the 'token' OR 'password' field of the DDNS entry.
> + Optional in ddns.conf:
> + proxied = false|true (default false; keep false for WireGuard)
> + ttl = 1|60|120... (default 1 = 'automatic')
> + """
> + handle = "cloudflare.com-v4"
> + name = "Cloudflare (v4)"
> + website = "https://www.cloudflare.com/"
> + protocols = ("ipv4",)
> + supports_token_auth = True
> + holdoff_failure_days = 0
> +
> + def _bool(self, key, default=False):
> + v = str(self.get(key, default)).strip().lower()
> + return v in ("1", "true", "yes", "on")
> +
> + def update(self):
> + import json, urllib.request, urllib.error
> +
> + tok = self.get("token") or self.get("password")
> + if not tok:
> + raise DDNSConfigurationError("API Token (password/token)
> is missing.")
> +
> + proxied = self._bool("proxied", False)
> + try:
> + ttl = int(self.get("ttl", 1))
> + except Exception:
> + ttl = 1
> +
> + headers = {
> + "Authorization": "Bearer {0}".format(tok),
> + "Content-Type": "application/json",
> + "User-Agent": "IPFireDDNSUpdater/CFv4",
> + }
> +
> + # --- find zone ---
> + parts = self.hostname.split(".")
> + if len(parts) < 2:
> + raise DDNSRequestError("Hostname '{0}' is not a valid
> domain.".format(self.hostname))
> +
> + zone_id = None
> + zone_name = None
> + for i in range(len(parts) - 1):
> + candidate = ".".join(parts[i:])
> + url =
> f"https://api.cloudflare.com/client/v4/zones?name={candidate}"
> + try:
> + req = urllib.request.Request(url, headers=headers,
> method="GET")
> + with urllib.request.urlopen(req, timeout=20) as r:
> + data = json.loads(r.read().decode())
> + except Exception as e:
> + raise DDNSUpdateError(f"Failed to query Cloudflare
> zones API: {e}")
> +
> + if data.get("success") and data.get("result"):
> + zone_id = data["result"][0]["id"]
> + zone_name = candidate
> + break
> +
> + if not zone_id:
> + raise DDNSRequestError(f"Could not find a Cloudflare Zone
> for '{self.hostname}'.")
> +
> + logger.info("CFv4: zone=%s id=%s", zone_name, zone_id)
> +
> + # --- get record ---
> + rec_url =
> f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type=A&name={self.hostname}"
> + try:
> + req = urllib.request.Request(rec_url, headers=headers,
> method="GET")
> + with urllib.request.urlopen(req, timeout=20) as r:
> + rec_data = json.loads(r.read().decode())
> + except Exception as e:
> + raise DDNSUpdateError(f"Failed to query Cloudflare DNS
> records API: {e}")
> +
> + if not rec_data.get("success"):
> + errs = rec_data.get("errors") or []
> + if any("Authentication error" in (e.get("message", "") or
> "") for e in errs):
> + raise DDNSAuthenticationError("Invalid API Token.")
> + raise DDNSUpdateError(f"Cloudflare API error finding
> record: {errs}")
> +
> + results = rec_data.get("result") or []
> + if not results:
> + raise DDNSRequestError(f"No A record found for
> '{self.hostname}' in zone '{zone_name}'.")
> +
> + record_id = results[0]["id"]
> + stored_ip = results[0]["content"]
> + logger.info("CFv4: record_id=%s stored_ip=%s", record_id, stored_ip)
> +
> + # --- compare IPs ---
> + current_ip = self.get_address("ipv4")
> + logger.info("CFv4: current_ip=%s vs stored_ip=%s",
> current_ip, stored_ip)
> + if current_ip == stored_ip:
> + logger.info("CFv4: no update needed")
> + return
> +
> + # --- update ---
> + upd_url =
> f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}"
> + payload = {
> + "type": "A",
> + "name": self.hostname,
> + "content": current_ip,
> + "ttl": ttl,
> + "proxied": proxied,
> + }
> + logger.info("CFv4: updating %s -> %s (proxied=%s ttl=%s)",
> self.hostname, current_ip, proxied, ttl)
> +
> + try:
> + req = urllib.request.Request(
> + upd_url, data=json.dumps(payload).encode(),
> headers=headers, method="PUT"
> + )
> + with urllib.request.urlopen(req, timeout=20) as r:
> + upd = json.loads(r.read().decode())
> + except Exception as e:
> + raise DDNSUpdateError(f"Failed to send update request to
> Cloudflare: {e}")
> +
> + if not upd.get("success"):
> + raise DDNSUpdateError(f"Cloudflare API error on update:
> {upd.get('errors')}")
> +
> + logger.info("CFv4: update ok for %s -> %s", self.hostname, current_ip)
> + return
> +
>
> class DDNSProtocolDynDNS2(object):
> """
>
^ permalink raw reply [flat|nested] 2+ messages in thread
end of thread, other threads:[~2025-08-29 9:51 UTC | newest]
Thread overview: 2+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-08-27 7:41 [PATCH] ddns: add Cloudflare (v4) provider using API token Chris Anton
2025-08-29 9:51 ` Michael Tremer
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox