This has been double-submitted here: https://lists.ipfire.org/ddns/[email protected]/T/#t
> On 27 Aug 2025, at 08:41, Chris Anton <[email protected]> wrote: > > From 563f089d0820bd61ad4aecac248d5cc1f2adfc81 Mon Sep 17 00:00:00 2001 > From: faithinchaos21 <[email protected]> > 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 <[email protected]> > --- > 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): > """ >
