From 837bafba096936cbdfab08e583dea0dcd1c7c241 Mon Sep 17 00:00:00 2001 From: Fredrick Amnehagen Date: Thu, 5 Feb 2026 19:15:50 +0100 Subject: [PATCH] feat: add cloudflare module for dynamic dns updates --- infra_cli/cloudflare.py | 73 +++++++++++++++++++++++++++++++++++++++++ infra_cli/main.py | 21 ++++++++++++ setup.py | 1 + 3 files changed, 95 insertions(+) create mode 100644 infra_cli/cloudflare.py diff --git a/infra_cli/cloudflare.py b/infra_cli/cloudflare.py new file mode 100644 index 0000000..80d81c9 --- /dev/null +++ b/infra_cli/cloudflare.py @@ -0,0 +1,73 @@ +import requests +import sys + +class CloudflareManager: + def __init__(self, config): + self.token = config.get('cloudflare.token') + self.ddns_domains = config.get('cloudflare.ddns_domains', []) + self.api_url = "https://api.cloudflare.com/client/v4" + self.headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + + def get_external_ip(self): + try: + return requests.get("https://checkip.amazonaws.com").text.strip() + except Exception as e: + print(f"Error fetching external IP: {e}") + return None + + def get_zone_id(self, domain): + res = requests.get(f"{self.api_url}/zones?name={domain}", headers=self.headers) + data = res.json() + if data.get('success') and data['result']: + return data['result'][0]['id'] + return None + + def update_ddns(self, force=False): + current_ip = self.get_external_ip() + if not current_ip: + return "Failed to determine current external IP." + + results = [] + for domain in self.ddns_domains: + zone_id = self.get_zone_id(domain) + if not zone_id: + results.append(f"[{domain}] Zone not found.") + continue + + # Find A record for the root domain + res = requests.get(f"{self.api_url}/zones/{zone_id}/dns_records?type=A&name={domain}", headers=self.headers) + records = res.json().get('result', []) + + if not records: + # Create if missing? For now, just report + results.append(f"[{domain}] No A record found to update.") + continue + + record = records[0] + if record['content'] == current_ip and not force: + results.append(f"[{domain}] Already up to date ({current_ip}).") + continue + + # Update record + update_data = { + "type": "A", + "name": domain, + "content": current_ip, + "ttl": 1, # Auto + "proxied": record.get('proxied', True) + } + u_res = requests.put(f"{self.api_url}/zones/{zone_id}/dns_records/{record['id']}", + headers=self.headers, json=update_data) + + if u_res.json().get('success'): + results.append(f"[{domain}] Updated to {current_ip}.") + else: + results.append(f"[{domain}] Update failed: {u_res.text}") + + return "\n".join(results) + + def list_domains(self): + return self.ddns_domains diff --git a/infra_cli/main.py b/infra_cli/main.py index 936a779..c7d4f53 100644 --- a/infra_cli/main.py +++ b/infra_cli/main.py @@ -5,6 +5,7 @@ from .ingress import IngressManager from .router import RouterManager from .proxmox import ProxmoxManager from .samba import SambaManager +from .cloudflare import CloudflareManager import sys @click.group() @@ -18,6 +19,26 @@ def cli(ctx, config): click.echo(f"Error: {e}", err=True) sys.exit(1) +@cli.group() +def cloudflare(): + """Manage Cloudflare DNS and DDNS""" + pass + +@cloudflare.command(name='list-ddns') +@click.pass_obj +def cf_list_ddns(config): + mgr = CloudflareManager(config) + for domain in mgr.list_domains(): + click.echo(domain) + +@cloudflare.command(name='update-ddns') +@click.option('--force', is_flag=True, help='Force update even if IP matches') +@click.pass_obj +def cf_update_ddns(config, force): + mgr = CloudflareManager(config) + click.echo("Updating Cloudflare DDNS records...") + click.echo(mgr.update_ddns(force)) + @cli.group() def proxmox(): """Manage Proxmox VMs and Containers""" diff --git a/setup.py b/setup.py index 5b6fafb..0ae8d11 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,7 @@ setup( install_requires=[ "click", "pyyaml", + "requests", ], entry_points={ "console_scripts": [