diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..970dd06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Environment +.env +.env.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ + +# Docker +data/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..272dec1 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Cloudflare DDNS Updater con UI + +Sistema automatico per aggiornare i record DNS di Cloudflare con il tuo IP pubblico dinamico, completo di interfaccia web per monitoraggio. + +## Caratteristiche + +- ✅ Aggiornamento automatico DNS tramite container `oznu/cloudflare-ddns` +- ✅ Interfaccia web Flask per monitoraggio in tempo reale +- ✅ Auto-refresh AJAX senza ricaricare la pagina +- ✅ Visualizzazione stato di sincronizzazione IP +- ✅ Cache intelligente per ridurre chiamate API +- ✅ Health check e logging strutturato +- ✅ Design responsive dark mode + +## Requisiti + +- Docker e Docker Compose +- Token API Cloudflare con permessi DNS edit +- Zona DNS configurata su Cloudflare + +## Configurazione + +1. Crea il file `.env` nella root del progetto: + +```env +# Cloudflare +CF_API_TOKEN=your_cloudflare_api_token +CF_ZONE=tuodomain.it +CF_SUBDOMAIN=subdomain +CF_PROXIED=false +CF_TTL=120 + +# Frequenza controllo IP (cron syntax) +CF_CRON=*/5 * * * * + +# Configurazione Container +DATA_VOLUME=./data +RESTART_POLICY=unless-stopped + +# UI Settings +UI_REFRESH_SECONDS=60 +UI_PORT=8088 +``` + +2. Avvia i container: + +```bash +docker-compose up -d +``` + +3. Accedi all'interfaccia web: + +``` +http://localhost:8088 +``` + +## Struttura Progetto + +``` +. +├── docker-compose.yml # Configurazione Docker +├── .env # Variabili ambiente (gitignored) +├── ui/ +│ ├── app.py # Server Flask +│ ├── cloudflare.py # API Cloudflare +│ ├── requirements.txt # Dipendenze Python +│ └── templates/ +│ └── index.html # UI web +└── data/ # Dati persistenti (gitignored) +``` + +## API Endpoints + +- `GET /` - Interfaccia web +- `GET /api/records` - JSON con stato record DNS +- `GET /health` - Health check + +## Monitoraggio + +L'interfaccia mostra per ogni record: +- Nome FQDN +- IP configurato nel DNS +- IP pubblico attuale +- Stato Proxy Cloudflare +- Stato sincronizzazione (OK/MISMATCH/ERROR) +- Ultimo aggiornamento + +## Ottimizzazioni + +- ✅ Cache Zone ID per evitare chiamate ripetute +- ✅ Timeout su tutte le richieste HTTP +- ✅ Validazione configurazione all'avvio +- ✅ CORS abilitato per integrazioni esterne +- ✅ Auto-refresh AJAX senza refresh completo +- ✅ Gestione errori e logging strutturato + +## Troubleshooting + +Verifica i log dei container: + +```bash +docker-compose logs cloudflare-ddns +docker-compose logs cloudflare-ddns-ui +``` + +Verifica l'health status: + +```bash +curl http://localhost:8088/health +``` + +## Licenza + +MIT diff --git a/docker-compose.yml b/docker-compose.yml index 5ccdc55..6b57883 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,14 @@ services: - ./ui:/app working_dir: /app command: > - sh -c "pip install --no-cache-dir flask requests && + sh -c "pip install --no-cache-dir -r requirements.txt && python app.py" ports: - "${UI_PORT}:5000" + healthcheck: + test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:5000/health')"] + interval: 30s + timeout: 10s + retries: 3 + depends_on: + - cloudflare-ddns diff --git a/ui/app.py b/ui/app.py index 98be295..23d6ad2 100644 --- a/ui/app.py +++ b/ui/app.py @@ -1,25 +1,61 @@ -from flask import Flask, render_template_string +from flask import Flask, render_template_string, jsonify +from flask_cors import CORS from cloudflare import get_dns_records +import os +import logging + +# Configurazione logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) app = Flask(__name__) +CORS(app) # Abilita CORS per richieste AJAX # ---------------------------- -# Template HTML già esistente +# Template HTML # ---------------------------- -TEMPLATE = open("templates/index.html").read() # Se preferisci usare file separato +with open("templates/index.html", encoding="utf-8") as f: + TEMPLATE = f.read() + +REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "60")) # ---------------------------- # Endpoint principale # ---------------------------- @app.route("/") def index(): - records = get_dns_records() - - # Passiamo i dati al template - return render_template_string(TEMPLATE, records=records) + try: + records = get_dns_records() + return render_template_string(TEMPLATE, records=records, refresh_seconds=REFRESH_SECONDS) + except Exception as e: + logger.error(f"Errore nel caricamento dei record: {e}") + return render_template_string(TEMPLATE, records=[], refresh_seconds=REFRESH_SECONDS) + +# ---------------------------- +# API endpoint per refresh AJAX +# ---------------------------- +@app.route("/api/records") +def api_records(): + try: + records = get_dns_records() + return jsonify({"success": True, "records": records}) + except Exception as e: + logger.error(f"Errore API: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +# ---------------------------- +# Health check +# ---------------------------- +@app.route("/health") +def health(): + return jsonify({"status": "ok"}), 200 # ---------------------------- # Avvio Flask # ---------------------------- if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) + logger.info(f"Avvio server su porta 5000 con refresh ogni {REFRESH_SECONDS}s") + app.run(host="0.0.0.0", port=5000, debug=False) diff --git a/ui/cloudflare.py b/ui/cloudflare.py index 9149e05..33d7715 100644 --- a/ui/cloudflare.py +++ b/ui/cloudflare.py @@ -5,10 +5,14 @@ from datetime import datetime # ---------------------------- # Config da .env # ---------------------------- -CF_API_TOKEN = os.getenv("CF_UI_API_TOKEN") +CF_API_TOKEN = os.getenv("CF_API_TOKEN") CF_ZONE = os.getenv("CF_ZONE") -CF_SUBDOMAINS = os.getenv("CF_UI_SUBDOMAINS", "").split(",") -PUBLIC_IP_API = os.getenv("PUBLIC_IP_API", "https://api.ipify.org") # opzionale +CF_SUBDOMAINS = [s.strip() for s in os.getenv("CF_SUBDOMAINS", "").split(",") if s.strip()] +PUBLIC_IP_API = os.getenv("PUBLIC_IP_API", "https://api.ipify.org") + +# Validazione configurazione +if not CF_API_TOKEN or not CF_ZONE or not CF_SUBDOMAINS: + raise ValueError("Mancano variabili ambiente: CF_API_TOKEN, CF_ZONE, CF_SUBDOMAINS") HEADERS = { "Authorization": f"Bearer {CF_API_TOKEN}", @@ -26,16 +30,24 @@ def get_public_ip(): except Exception: return "N/A" +_zone_id_cache = None + def get_zone_id(): - """Recupera l'ID della zona Cloudflare""" + """Recupera l'ID della zona Cloudflare (con cache)""" + global _zone_id_cache + if _zone_id_cache: + return _zone_id_cache + try: resp = requests.get( "https://api.cloudflare.com/client/v4/zones", headers=HEADERS, - params={"name": CF_ZONE} + params={"name": CF_ZONE}, + timeout=10 ).json() if resp["success"] and resp["result"]: - return resp["result"][0]["id"] + _zone_id_cache = resp["result"][0]["id"] + return _zone_id_cache else: raise Exception(f"Zona '{CF_ZONE}' non trovata o errore API.") except Exception as e: @@ -57,40 +69,47 @@ def get_dns_records(): resp = requests.get( f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records", headers=HEADERS, - params={"name": fqdn} + params={"name": fqdn}, + timeout=10 ).json() if not resp["success"] or not resp["result"]: # Record non trovato + now = datetime.utcnow().isoformat() records.append({ "name": fqdn, "dns_ip": "N/A", "public_ip": public_ip, "proxied": False, "status": "MISSING", - "last_updated": datetime.utcnow().isoformat() + "last_updated": now, + "last_updated_human": now }) continue dns = resp["result"][0] + last_updated = dns.get("modified_on", datetime.utcnow().isoformat()) records.append({ "name": fqdn, "dns_ip": dns["content"], "public_ip": public_ip, "proxied": dns["proxied"], "status": "OK" if dns["content"] == public_ip else "MISMATCH", - "last_updated": dns.get("modified_on", datetime.utcnow().isoformat()) + "last_updated": last_updated, + "last_updated_human": last_updated }) except Exception as e: print(f"[ERROR] get_dns_records ({fqdn}): {e}") + now = datetime.utcnow().isoformat() records.append({ "name": fqdn, "dns_ip": "ERROR", "public_ip": public_ip, "proxied": False, "status": "ERROR", - "last_updated": datetime.utcnow().isoformat() + "last_updated": now, + "last_updated_human": now }) return records diff --git a/ui/requirements.txt b/ui/requirements.txt new file mode 100644 index 0000000..04d0ffb --- /dev/null +++ b/ui/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.0 +flask-cors==5.0.0 +requests==2.32.3 diff --git a/ui/templates/index.html b/ui/templates/index.html index 42f58f7..3dad92b 100644 --- a/ui/templates/index.html +++ b/ui/templates/index.html @@ -112,8 +112,8 @@ -