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
|
- ./ui:/app
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: >
|
command: >
|
||||||
sh -c "pip install --no-cache-dir flask requests &&
|
sh -c "pip install --no-cache-dir -r requirements.txt &&
|
||||||
python app.py"
|
python app.py"
|
||||||
ports:
|
ports:
|
||||||
- "${UI_PORT}:5000"
|
- "${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
|
||||||
|
|||||||
52
ui/app.py
52
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
|
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__)
|
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
|
# Endpoint principale
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
records = get_dns_records()
|
try:
|
||||||
|
records = get_dns_records()
|
||||||
# Passiamo i dati al template
|
return render_template_string(TEMPLATE, records=records, refresh_seconds=REFRESH_SECONDS)
|
||||||
return render_template_string(TEMPLATE, records=records)
|
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
|
# Avvio Flask
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
if __name__ == "__main__":
|
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
|
# 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_ZONE = os.getenv("CF_ZONE")
|
||||||
CF_SUBDOMAINS = os.getenv("CF_UI_SUBDOMAINS", "").split(",")
|
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") # opzionale
|
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 = {
|
HEADERS = {
|
||||||
"Authorization": f"Bearer {CF_API_TOKEN}",
|
"Authorization": f"Bearer {CF_API_TOKEN}",
|
||||||
@@ -26,16 +30,24 @@ def get_public_ip():
|
|||||||
except Exception:
|
except Exception:
|
||||||
return "N/A"
|
return "N/A"
|
||||||
|
|
||||||
|
_zone_id_cache = None
|
||||||
|
|
||||||
def get_zone_id():
|
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:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
"https://api.cloudflare.com/client/v4/zones",
|
"https://api.cloudflare.com/client/v4/zones",
|
||||||
headers=HEADERS,
|
headers=HEADERS,
|
||||||
params={"name": CF_ZONE}
|
params={"name": CF_ZONE},
|
||||||
|
timeout=10
|
||||||
).json()
|
).json()
|
||||||
if resp["success"] and resp["result"]:
|
if resp["success"] and resp["result"]:
|
||||||
return resp["result"][0]["id"]
|
_zone_id_cache = resp["result"][0]["id"]
|
||||||
|
return _zone_id_cache
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Zona '{CF_ZONE}' non trovata o errore API.")
|
raise Exception(f"Zona '{CF_ZONE}' non trovata o errore API.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -57,40 +69,47 @@ def get_dns_records():
|
|||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
|
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
|
||||||
headers=HEADERS,
|
headers=HEADERS,
|
||||||
params={"name": fqdn}
|
params={"name": fqdn},
|
||||||
|
timeout=10
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
if not resp["success"] or not resp["result"]:
|
if not resp["success"] or not resp["result"]:
|
||||||
# Record non trovato
|
# Record non trovato
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
records.append({
|
records.append({
|
||||||
"name": fqdn,
|
"name": fqdn,
|
||||||
"dns_ip": "N/A",
|
"dns_ip": "N/A",
|
||||||
"public_ip": public_ip,
|
"public_ip": public_ip,
|
||||||
"proxied": False,
|
"proxied": False,
|
||||||
"status": "MISSING",
|
"status": "MISSING",
|
||||||
"last_updated": datetime.utcnow().isoformat()
|
"last_updated": now,
|
||||||
|
"last_updated_human": now
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dns = resp["result"][0]
|
dns = resp["result"][0]
|
||||||
|
last_updated = dns.get("modified_on", datetime.utcnow().isoformat())
|
||||||
records.append({
|
records.append({
|
||||||
"name": fqdn,
|
"name": fqdn,
|
||||||
"dns_ip": dns["content"],
|
"dns_ip": dns["content"],
|
||||||
"public_ip": public_ip,
|
"public_ip": public_ip,
|
||||||
"proxied": dns["proxied"],
|
"proxied": dns["proxied"],
|
||||||
"status": "OK" if dns["content"] == public_ip else "MISMATCH",
|
"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:
|
except Exception as e:
|
||||||
print(f"[ERROR] get_dns_records ({fqdn}): {e}")
|
print(f"[ERROR] get_dns_records ({fqdn}): {e}")
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
records.append({
|
records.append({
|
||||||
"name": fqdn,
|
"name": fqdn,
|
||||||
"dns_ip": "ERROR",
|
"dns_ip": "ERROR",
|
||||||
"public_ip": public_ip,
|
"public_ip": public_ip,
|
||||||
"proxied": False,
|
"proxied": False,
|
||||||
"status": "ERROR",
|
"status": "ERROR",
|
||||||
"last_updated": datetime.utcnow().isoformat()
|
"last_updated": now,
|
||||||
|
"last_updated_human": now
|
||||||
})
|
})
|
||||||
|
|
||||||
return records
|
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>
|
</table>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer id="status">
|
||||||
Visualizzazione aggiornata ogni 60 secondi
|
Visualizzazione aggiornata ogni {{ refresh_seconds }} secondi | Ultimo aggiornamento: <span id="last-update">ora</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -128,9 +128,41 @@ function timeAgo(date) {
|
|||||||
return "pochi secondi fa";
|
return "pochi secondi fa";
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.time').forEach(td => {
|
function updateTimeAgo() {
|
||||||
td.textContent = timeAgo(td.dataset.timestamp);
|
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>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user