enhancement
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -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
|
||||
114
README.md
Normal file
114
README.md
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
48
ui/app.py
48
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():
|
||||
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)
|
||||
|
||||
# Passiamo i dati al template
|
||||
return render_template_string(TEMPLATE, records=records)
|
||||
# ----------------------------
|
||||
# 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
3
ui/requirements.txt
Normal file
3
ui/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask==3.1.0
|
||||
flask-cors==5.0.0
|
||||
requests==2.32.3
|
||||
@@ -112,8 +112,8 @@
|
||||
</table>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Visualizzazione aggiornata ogni 60 secondi
|
||||
<footer id="status">
|
||||
Visualizzazione aggiornata ogni {{ refresh_seconds }} secondi | Ultimo aggiornamento: <span id="last-update">ora</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
@@ -128,9 +128,41 @@ function timeAgo(date) {
|
||||
return "pochi secondi fa";
|
||||
}
|
||||
|
||||
document.querySelectorAll('.time').forEach(td => {
|
||||
function updateTimeAgo() {
|
||||
document.querySelectorAll('.time').forEach(td => {
|
||||
td.textContent = timeAgo(td.dataset.timestamp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateRecords() {
|
||||
fetch('/api/records')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const tbody = document.querySelector('tbody');
|
||||
tbody.innerHTML = data.records.map(r => `
|
||||
<tr>
|
||||
<td>${r.name}</td>
|
||||
<td>${r.dns_ip}</td>
|
||||
<td>${r.public_ip}</td>
|
||||
<td>${r.proxied ? 'ON' : 'OFF'}</td>
|
||||
<td><span class="badge ${r.status === 'OK' ? 'ok' : 'bad'}">${r.status}</span></td>
|
||||
<td class="time" data-timestamp="${r.last_updated}">${timeAgo(r.last_updated)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
document.getElementById('last-update').textContent = new Date().toLocaleTimeString('it-IT');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Errore aggiornamento:', err);
|
||||
document.getElementById('status').style.color = '#ff5252';
|
||||
});
|
||||
}
|
||||
|
||||
// Init
|
||||
updateTimeAgo();
|
||||
setInterval(updateTimeAgo, 5000);
|
||||
setInterval(updateRecords, {{ refresh_seconds }} * 1000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user