From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from mail02.haj.ipfire.org (localhost [IPv6:::1]) by mail02.haj.ipfire.org (Postfix) with ESMTP id 4cCtnS12sVz30Lk for ; Fri, 29 Aug 2025 09:51:08 +0000 (UTC) Received: from mail01.ipfire.org (mail01.haj.ipfire.org [172.28.1.202]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange x25519) (Client CN "mail01.haj.ipfire.org", Issuer "R13" (verified OK)) by mail02.haj.ipfire.org (Postfix) with ESMTPS id 4cCtnN5Bdcz2xDp for ; Fri, 29 Aug 2025 09:51:04 +0000 (UTC) Received: from [127.0.0.1] (localhost [127.0.0.1]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by mail01.ipfire.org (Postfix) with ESMTPSA id 4cCtnN0vG0z32l; Fri, 29 Aug 2025 09:51:04 +0000 (UTC) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=ipfire.org; s=202003rsa; t=1756461064; h=from:from:reply-to:subject:subject:date:date:message-id:message-id: to:to:cc:cc:mime-version:mime-version:content-type:content-type: content-transfer-encoding:content-transfer-encoding: in-reply-to:in-reply-to:references:references; bh=kdnPRvFCdlKDS8hKN/3YcFcp36oABfpCewJ+xDLvQLU=; b=t98ZbN/IOEREAyqc0/SCVjC/vSeel92cD7pmo09dluQjGsyWSP0oHJz7MgEahUKxwj6Ia0 b22eeP8KpErZFstaRVb3kKWNiWmC1GyeHDEfefyJFGsT1UvnV/SO37YB2KRrQqQfh2GkUE +sf2kO/GRlPLQVVRSE70xAZV3KFHa1AwCjTzHXsKvfaySe2OHiCJAMROxwaWIUJ/Ov/acc Qa56cOUqlaWT2Lj12f5ftHSsaTPlCF8ZEdqvYlw38fJ6r3sKm4l3PfCafINoUae87eKy+8 qfZkrT2YDe3fQ4DV9L7MIDSPytm8M8R2Fr8sXbwenM9lC7exvxh/2bhpkV0CVw== DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=ipfire.org; s=202003ed25519; t=1756461064; h=from:from:reply-to:subject:subject:date:date:message-id:message-id: to:to:cc:cc:mime-version:mime-version:content-type:content-type: content-transfer-encoding:content-transfer-encoding: in-reply-to:in-reply-to:references:references; bh=kdnPRvFCdlKDS8hKN/3YcFcp36oABfpCewJ+xDLvQLU=; b=JxJ8yOYeDjGL4cAPVEsni5QpZVxijOMYuzwCWGw4ejgJ0NRNjLFcQkSjatyzFz1WriVZXJ qati3VFgb1uVOAAA== Content-Type: text/plain; charset=utf-8 Precedence: list List-Id: List-Subscribe: , List-Unsubscribe: , List-Post: List-Help: Sender: Mail-Followup-To: Mime-Version: 1.0 Subject: Re: [PATCH] ddns: add Cloudflare (v4) provider using API token From: Michael Tremer In-Reply-To: Date: Fri, 29 Aug 2025 10:51:04 +0100 Cc: development@lists.ipfire.org Content-Transfer-Encoding: quoted-printable Message-Id: <185D18B1-4582-4D6D-942E-C74E3EDDB87C@ipfire.org> References: To: Chris Anton 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 wrote: >=20 > =46rom 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=3DUTF-8 > Content-Transfer-Encoding: 8bit >=20 > This adds a provider =E2=80=9Ccloudflare.com-v4=E2=80=9D that updates = an A record > via Cloudflare=E2=80=99s v4 API using a Bearer token. The token is = accepted > from either =E2=80=98token=E2=80=99 or legacy =E2=80=98password=E2=80=99= for UI compatibility. >=20 > 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 >=20 > Signed-off-by: Chris Anton > --- > src/ddns/providers.py | 121 ++++++++++++++++++++++++++++++++++++++++++ > 1 file changed, 121 insertions(+) >=20 > 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): >=20 > return False >=20 > +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 =3D false|true (default false; keep false for = WireGuard) > + ttl =3D 1|60|120... (default 1 =3D 'automatic') > + """ > + handle =3D "cloudflare.com-v4" > + name =3D "Cloudflare (v4)" > + website =3D "https://www.cloudflare.com/" > + protocols =3D ("ipv4",) > + supports_token_auth =3D True > + holdoff_failure_days =3D 0 > + > + def _bool(self, key, default=3DFalse): > + v =3D str(self.get(key, default)).strip().lower() > + return v in ("1", "true", "yes", "on") > + > + def update(self): > + import json, urllib.request, urllib.error > + > + tok =3D self.get("token") or self.get("password") > + if not tok: > + raise DDNSConfigurationError("API Token (password/token) > is missing.") > + > + proxied =3D self._bool("proxied", False) > + try: > + ttl =3D int(self.get("ttl", 1)) > + except Exception: > + ttl =3D 1 > + > + headers =3D { > + "Authorization": "Bearer {0}".format(tok), > + "Content-Type": "application/json", > + "User-Agent": "IPFireDDNSUpdater/CFv4", > + } > + > + # --- find zone --- > + parts =3D self.hostname.split(".") > + if len(parts) < 2: > + raise DDNSRequestError("Hostname '{0}' is not a valid > domain.".format(self.hostname)) > + > + zone_id =3D None > + zone_name =3D None > + for i in range(len(parts) - 1): > + candidate =3D ".".join(parts[i:]) > + url =3D > f"https://api.cloudflare.com/client/v4/zones?name=3D{candidate}" > + try: > + req =3D urllib.request.Request(url, headers=3Dheaders, > method=3D"GET") > + with urllib.request.urlopen(req, timeout=3D20) as r: > + data =3D 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 =3D data["result"][0]["id"] > + zone_name =3D candidate > + break > + > + if not zone_id: > + raise DDNSRequestError(f"Could not find a Cloudflare Zone > for '{self.hostname}'.") > + > + logger.info("CFv4: zone=3D%s id=3D%s", zone_name, zone_id) > + > + # --- get record --- > + rec_url =3D > = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type=3D= A&name=3D{self.hostname}" > + try: > + req =3D urllib.request.Request(rec_url, headers=3Dheaders, > method=3D"GET") > + with urllib.request.urlopen(req, timeout=3D20) as r: > + rec_data =3D 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 =3D 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 =3D rec_data.get("result") or [] > + if not results: > + raise DDNSRequestError(f"No A record found for > '{self.hostname}' in zone '{zone_name}'.") > + > + record_id =3D results[0]["id"] > + stored_ip =3D results[0]["content"] > + logger.info("CFv4: record_id=3D%s stored_ip=3D%s", record_id, = stored_ip) > + > + # --- compare IPs --- > + current_ip =3D self.get_address("ipv4") > + logger.info("CFv4: current_ip=3D%s vs stored_ip=3D%s", > current_ip, stored_ip) > + if current_ip =3D=3D stored_ip: > + logger.info("CFv4: no update needed") > + return > + > + # --- update --- > + upd_url =3D > = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record= _id}" > + payload =3D { > + "type": "A", > + "name": self.hostname, > + "content": current_ip, > + "ttl": ttl, > + "proxied": proxied, > + } > + logger.info("CFv4: updating %s -> %s (proxied=3D%s ttl=3D%s)", > self.hostname, current_ip, proxied, ttl) > + > + try: > + req =3D urllib.request.Request( > + upd_url, data=3Djson.dumps(payload).encode(), > headers=3Dheaders, method=3D"PUT" > + ) > + with urllib.request.urlopen(req, timeout=3D20) as r: > + upd =3D 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 > + >=20 > class DDNSProtocolDynDNS2(object): > """ >=20