public inbox for development@lists.ipfire.org
 help / color / mirror / Atom feed
From: Michael Tremer <michael.tremer@ipfire.org>
To: Chris Anton <chris.v.anton@gmail.com>
Cc: development@lists.ipfire.org
Subject: Re: [PATCH] ddns: add Cloudflare (v4) provider using API token
Date: Fri, 29 Aug 2025 10:51:04 +0100	[thread overview]
Message-ID: <185D18B1-4582-4D6D-942E-C74E3EDDB87C@ipfire.org> (raw)
In-Reply-To: <CAJ2Zu3Cn3Y3M7kqBuhiGmpSrcZ_u1VCOMk1tN1ed1w760e=gJw@mail.gmail.com>

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):
>  """
> 



      reply	other threads:[~2025-08-29  9:51 UTC|newest]

Thread overview: 2+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-08-27  7:41 Chris Anton
2025-08-29  9:51 ` Michael Tremer [this message]

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=185D18B1-4582-4D6D-942E-C74E3EDDB87C@ipfire.org \
    --to=michael.tremer@ipfire.org \
    --cc=chris.v.anton@gmail.com \
    --cc=development@lists.ipfire.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox