diff --git a/cf.py b/cf.py new file mode 100644 index 0000000..c1ed1e4 --- /dev/null +++ b/cf.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 + +import os +import sys +import yaml +import requests +import subprocess +from datetime import datetime +from loguru import logger +from dotenv import load_dotenv +from pathlib import Path + +# Constants +SCRIPT_DIR = Path(__file__).resolve().parent +CADDYFILE_PATH = "/etc/caddy/Caddyfile" +CF_DOMAINS_FILE = SCRIPT_DIR / 'cf_domains.yaml' +ENV_FILE = SCRIPT_DIR / '.env' +LOG_FILE = SCRIPT_DIR / 'cf_script.log' + +# Load environment variables +load_dotenv(ENV_FILE) + +# Configure logger +logger.remove() # Remove default handler +logger.add(sys.stderr, format="{time:YYYY-MM-DD HH:mm:ss} {level} {message}", level="INFO", colorize=True) +logger.add(LOG_FILE, format="{time:YYYY-MM-DD HH:mm:ss} {level} {message}", rotation="10 MB", retention="1 week", level="DEBUG") + +def get_current_ip(): + return requests.get('https://am.i.mullvad.net/ip').text.strip() + +def update_env_ip(ip): + env_vars = {} + if ENV_FILE.exists(): + with open(ENV_FILE, 'r') as f: + for line in f: + if '=' in line: + key, value = line.strip().split('=', 1) + env_vars[key] = value + + env_vars['CURRENT_IP'] = ip + + with open(ENV_FILE, 'w') as f: + for key, value in env_vars.items(): + f.write(f"{key}={value}\n") + +def sort_yaml(): + with open(CF_DOMAINS_FILE, 'r') as file: + data = yaml.safe_load(file) + + sorted_data = dict(sorted(data.items())) + + for domain, subdomains in sorted_data.items(): + if isinstance(subdomains, dict): + sorted_data[domain] = dict(sorted(subdomains.items())) + + with open(CF_DOMAINS_FILE, 'w') as file: + yaml.dump(sorted_data, file, sort_keys=False) + +def usage(): + logger.error("Incorrect usage") + print("Usage: cf [--ip ] --port ") + print(" cf ddns [--force]") + print(" cf all [--force]") + sys.exit(1) + +def update_caddyfile(full_domain, caddy_ip, port): + with open(CADDYFILE_PATH, 'a') as file: + file.write(f""" +{full_domain} {{ + reverse_proxy {caddy_ip}:{port} + tls {{ + dns cloudflare {{env.CLOUDFLARE_API_TOKEN}} + }} +}} +""") + logger.info(f"Configuration appended to {CADDYFILE_PATH} with correct formatting.") + +def update_dns_record(zone_id, dns_id, name, ip): + url = f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{dns_id}' + headers = { + 'Authorization': f'Bearer {os.getenv("CLOUDFLARE_API_TOKEN")}', + 'Content-Type': 'application/json' + } + data = { + 'type': 'A', + 'name': name, + 'content': ip, + 'ttl': 120, + 'proxied': True + } + response = requests.put(url, headers=headers, json=data) + return response.json() + +def ddns(force=False): + current_ip = get_current_ip() + last_ip = os.getenv('CURRENT_IP') + + if current_ip != last_ip or force: + update_env_ip(current_ip) + + with open(CF_DOMAINS_FILE, 'r') as f: + domains = yaml.safe_load(f) + + for domain, data in domains.items(): + zone_id = data['_id'] + for subdomain, dns_id in data.items(): + if subdomain == '_id': + continue + full_domain = f"{subdomain}.{domain}" if subdomain != '@' else domain + logger.info(f"Updating {full_domain}") + result = update_dns_record(zone_id, dns_id, full_domain, current_ip) + if result.get('success'): + logger.info(f"Successfully updated {full_domain}") + else: + logger.error(f"Failed to update {full_domain}: {result.get('errors', 'Unknown error')}") + + logger.info(f"DDNS update completed at {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC") + else: + logger.info("IP address hasn't changed. No updates needed.") + +def get_existing_record(zone_id, name): + url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type=A&name={name}" + headers = { + "Authorization": f"Bearer {os.getenv('CLOUDFLARE_API_TOKEN')}", + "Content-Type": "application/json" + } + response = requests.get(url, headers=headers) + result = response.json() + if result.get('success') and result['result']: + return result['result'][0] + return None + +def update_or_create_record(zone_id, subdomain, domain, cloudflare_ip): + # For root domain, we need to use the full domain name instead of "@" + full_name = domain if subdomain == "@" else f"{subdomain}.{domain}" + + existing_record = get_existing_record(zone_id, full_name) + + headers = { + "Authorization": f"Bearer {os.getenv('CLOUDFLARE_API_TOKEN')}", + "Content-Type": "application/json" + } + data = { + "type": "A", + "name": full_name, + "content": cloudflare_ip, + "ttl": 120, + "proxied": True + } + + if existing_record: + # Check if the existing record has the same content + if existing_record['content'] == cloudflare_ip: + logger.info(f"Record for {full_name} already exists with the correct IP. No update needed.") + return {"success": True, "result": existing_record} + + url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{existing_record['id']}" + response = requests.put(url, headers=headers, json=data) + else: + url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" + response = requests.post(url, headers=headers, json=data) + + result = response.json() + + # Handle the specific error code + if not result.get('success') and result.get('errors'): + for error in result['errors']: + if error.get('code') == 81058: # "A record with the same settings already exists" + logger.info(f"Record for {full_name} already exists with the correct settings. No update needed.") + return {"success": True, "result": existing_record or data} + + return result + +def main(): + if len(sys.argv) < 2: + usage() + + if sys.argv[1] in ['ddns', 'all']: + force = '--force' in sys.argv + ddns(force) + if sys.argv[1] == 'ddns': + return + + if os.geteuid() != 0 and sys.argv[1] not in ['ddns', 'all']: + logger.error("This script must be run as root for Caddyfile modifications. Try using 'sudo'.") + sys.exit(1) + + full_domain = sys.argv[1] + caddy_ip = "localhost" + port = None + + i = 2 + while i < len(sys.argv): + if sys.argv[i] in ['--ip', '-i']: + caddy_ip = sys.argv[i+1] + i += 2 + elif sys.argv[i] in ['--port', '-p']: + port = sys.argv[i+1] + i += 2 + else: + usage() + + if not port: + usage() + + parts = full_domain.split('.') + if len(parts) == 2: + domain = full_domain + subdomain = "@" + else: + subdomain = parts[0] + domain = '.'.join(parts[1:]) + + with open(CF_DOMAINS_FILE, 'r') as file: + cf_domains = yaml.safe_load(file) + + if domain not in cf_domains: + logger.error(f"Domain {domain} not found in cf_domains.yaml") + sys.exit(1) + + zone_id = cf_domains[domain].get('_id') + if not zone_id: + logger.error(f"Zone ID for {domain} could not be found.") + sys.exit(1) + + cloudflare_ip = os.getenv('CURRENT_IP') + + result = update_or_create_record(zone_id, subdomain, domain, cloudflare_ip) + + if result.get('success'): + record_id = result['result'].get('id') + if record_id: + if subdomain == "@": + cf_domains[domain]["@"] = record_id + else: + cf_domains[domain][subdomain] = record_id + with open(CF_DOMAINS_FILE, 'w') as file: + yaml.dump(cf_domains, file) + logger.info(f"A record for {full_domain} created/updated and cf_domains.yaml updated successfully.") + else: + logger.info(f"A record for {full_domain} already exists with correct settings. No update needed.") + sort_yaml() + logger.info("YAML file sorted after update.") + update_caddyfile(full_domain, caddy_ip, port) + else: + error_message = result.get('errors', [{'message': 'Unknown error', 'code': 'N/A'}])[0] + logger.error(f"Failed to create/update A record for {full_domain}. Error: {error_message.get('message')} (Code: {error_message.get('code')})") + + sort_yaml() + logger.info("YAML file sorted at end.") + + logger.info("Restarting caddy!") + subprocess.run(['sudo', 'systemctl', 'restart', 'caddy'], check=True) + +if __name__ == "__main__": + main() \ No newline at end of file