From 01749f96e86e40bd5c5c302063d3a0119daf5118 Mon Sep 17 00:00:00 2001 From: truetype Date: Fri, 23 Jan 2026 18:57:33 +0100 Subject: [PATCH] Add project sources --- .env.example | 4 + .gitignore | 8 ++ app.py | 97 ++++++++++++++ config.py | 9 ++ ping_service.py | 102 +++++++++++++++ requirements.txt | 4 + static/dashboard.js | 184 +++++++++++++++++++++++++++ static/style.css | 304 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 712 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app.py create mode 100644 config.py create mode 100644 ping_service.py create mode 100644 requirements.txt create mode 100644 static/dashboard.js create mode 100644 static/style.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a5624bb --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# IMPORTANTE: Crea questo file localmente e NON commitarlo +# Sostituisci xxxxx con il tuo token ZeroTier effettivo + +ZEROTIER_TOKEN=xxxxx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7fd9fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.pyc +__pycache__/ +venv/ +.env +.DS_Store +.vscode/ +*.log +.idea/ diff --git a/app.py b/app.py new file mode 100644 index 0000000..c348bbf --- /dev/null +++ b/app.py @@ -0,0 +1,97 @@ +import logging +from flask import Flask, render_template, jsonify, request +from config import DEBUG, ZEROTIER_TOKEN +from zerotier_client import ZeroTierClient +from ping_service import PingService + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = Flask(__name__) +zt_client = ZeroTierClient() +ping_service = PingService() + +# Variabile globale per network_id +current_network_id = None + +@app.route('/') +def home(): + """Pagina principale - dashboard""" + return render_template('dashboard.html') + +@app.route('/api/networks', methods=['GET']) +def get_networks(): + """Ritorna la lista delle reti ZeroTier connesse""" + try: + # ZeroTier API doesn't list networks directly, + # l'utente deve impostare manualmente il network_id + return jsonify({ + 'message': 'Use /api/nodes?network_id= to get nodes', + 'example': '/api/nodes?network_id=xxxxxxxxxxxxxx' + }), 200 + except Exception as e: + logger.error(f'Error in get_networks: {e}') + return jsonify({'error': str(e)}), 500 + +@app.route('/api/nodes', methods=['GET']) +def get_nodes(): + """Ritorna lo stato di tutti i nodi della rete""" + global current_network_id + + try: + network_id = request.args.get('network_id') + + if not network_id: + return jsonify({'error': 'network_id parameter required'}), 400 + + # Se è un network_id nuovo, avvia il servizio di ping + if network_id != current_network_id: + if ping_service.running: + ping_service.stop() + current_network_id = network_id + ping_service.start(network_id) + + nodes = ping_service.get_nodes_status() + + return jsonify({ + 'network_id': network_id, + 'total_nodes': len(nodes), + 'online_nodes': sum(1 for n in nodes if n['online']), + 'nodes': nodes + }), 200 + except Exception as e: + logger.error(f'Error in get_nodes: {e}') + return jsonify({'error': str(e)}), 500 + +@app.route('/api/status', methods=['GET']) +def api_status(): + """Health check dell'API""" + return jsonify({ + 'status': 'ok', + 'zerotier_connected': zt_client.get_self() is not None + }), 200 + +@app.before_request +def check_auth(): + """Verifica il token per le API""" + # Se necessario aggiungere autenticazione + pass + +@app.errorhandler(404) +def not_found(error): + return jsonify({'error': 'Not found'}), 404 + +@app.errorhandler(500) +def internal_error(error): + logger.error(f'Internal error: {error}') + return jsonify({'error': 'Internal server error'}), 500 + +if __name__ == '__main__': + try: + logger.info('Starting ZeroCentral...') + app.run(debug=DEBUG, host='0.0.0.0', port=5000) + except KeyboardInterrupt: + logger.info('Shutting down...') + if ping_service.running: + ping_service.stop() diff --git a/config.py b/config.py new file mode 100644 index 0000000..0d60907 --- /dev/null +++ b/config.py @@ -0,0 +1,9 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +ZEROTIER_TOKEN = os.getenv('ZEROTOKEN') +ZEROTIER_API_URL = 'https://api.zerotier.com/api/v1' +PING_INTERVAL = 10 # secondi +DEBUG = True diff --git a/ping_service.py b/ping_service.py new file mode 100644 index 0000000..1003da2 --- /dev/null +++ b/ping_service.py @@ -0,0 +1,102 @@ +import threading +import time +import subprocess +import logging +from datetime import datetime +from zerotier_client import ZeroTierClient +from config import PING_INTERVAL + +logger = logging.getLogger(__name__) + +class PingService: + def __init__(self): + self.zt_client = ZeroTierClient() + self.nodes_status = {} + self.running = False + self.thread = None + self.network_id = None + + def set_network_id(self, network_id): + """Imposta l'ID della rete da monitorare""" + self.network_id = network_id + + def ping_node(self, ip_address): + """Fa il ping a un nodo e ritorna True se online""" + try: + result = subprocess.run( + ['ping', '-c', '1', '-W', '3', ip_address], + capture_output=True, + timeout=5 + ) + return result.returncode == 0 + except Exception as e: + logger.error(f'Ping error for {ip_address}: {e}') + return False + + def update_node_status(self): + """Aggiorna lo stato di tutti i nodi""" + if not self.network_id: + logger.warning('Network ID not set') + return + + peers = self.zt_client.get_peers(self.network_id) + current_time = datetime.now().isoformat() + + for peer in peers: + peer_id = peer.get('id') + hostname = peer.get('name', 'Unknown') + config = peer.get('config', {}) + + # Estrai IP ZeroTier dal config + ip_addresses = config.get('ipAssignments', []) + if not ip_addresses: + continue + + zt_ip = ip_addresses[0] + + # Fa il ping + is_online = self.ping_node(zt_ip) + + self.nodes_status[peer_id] = { + 'id': peer_id, + 'hostname': hostname, + 'ip': zt_ip, + 'online': is_online, + 'status': 'online' if is_online else 'offline', + 'last_check': current_time + } + + logger.info(f'Updated status for {len(self.nodes_status)} nodes') + + def get_nodes_status(self): + """Ritorna lo stato di tutti i nodi""" + return list(self.nodes_status.values()) + + def start(self, network_id): + """Avvia il servizio di ping in background""" + if self.running: + logger.warning('Service already running') + return + + self.network_id = network_id + self.running = True + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + logger.info('Ping service started') + + def _run(self): + """Loop principale del servizio""" + while self.running: + try: + self.update_node_status() + except Exception as e: + logger.error(f'Error in ping service: {e}') + + time.sleep(PING_INTERVAL) + + def stop(self): + """Arresta il servizio""" + self.running = False + if self.thread: + self.thread.join(timeout=5) + logger.info('Ping service stopped') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c663de2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask==3.0.0 +werkzeug==3.0.1 +python-dotenv==1.0.0 +requests==2.31.0 diff --git a/static/dashboard.js b/static/dashboard.js new file mode 100644 index 0000000..d562144 --- /dev/null +++ b/static/dashboard.js @@ -0,0 +1,184 @@ +let currentNetworkId = null; +let updateInterval = null; + +async function setNetwork() { + const networkId = document.getElementById('networkId').value.trim(); + + if (!networkId) { + showError('Inserisci un Network ID'); + return; + } + + if (networkId.length !== 16) { + showError('Il Network ID deve essere lungo 16 caratteri'); + return; + } + + currentNetworkId = networkId; + clearError(); + + // Avvia il polling automatico + if (updateInterval) { + clearInterval(updateInterval); + } + + // Primo aggiornamento immediato + await updateNodes(); + + // Poi ogni 5 secondi + updateInterval = setInterval(updateNodes, 5000); +} + +async function updateNodes() { + if (!currentNetworkId) { + return; + } + + try { + const response = await fetch(`/api/nodes?network_id=${currentNetworkId}`); + + if (!response.ok) { + const error = await response.json(); + console.error('API Error:', error); + updateStatusIndicator(false); + showError(error.error || 'Errore nel caricamento dei nodi'); + return; + } + + const data = await response.json(); + updateStatusIndicator(true); + renderNodes(data); + updateStats(data); + clearError(); + + } catch (error) { + console.error('Fetch error:', error); + updateStatusIndicator(false); + showError('Errore di connessione al server'); + } +} + +function updateStatusIndicator(isOnline) { + const indicator = document.getElementById('status-indicator'); + if (isOnline) { + indicator.classList.remove('offline'); + indicator.classList.add('online'); + } else { + indicator.classList.remove('online'); + indicator.classList.add('offline'); + } +} + +function showError(message) { + let errorDiv = document.getElementById('error-message'); + if (!errorDiv) { + errorDiv = document.createElement('div'); + errorDiv.id = 'error-message'; + errorDiv.className = 'error-banner'; + document.querySelector('.container').insertBefore(errorDiv, document.querySelector('header').nextSibling); + } + errorDiv.textContent = message; + errorDiv.style.display = 'block'; +} + +function clearError() { + const errorDiv = document.getElementById('error-message'); + if (errorDiv) { + errorDiv.style.display = 'none'; + } +} + +function updateStats(data) { + document.getElementById('total-nodes').textContent = data.total_nodes || 0; + document.getElementById('online-nodes').textContent = data.online_nodes || 0; + + const now = new Date(); + const timeString = now.toLocaleTimeString('it-IT'); + document.getElementById('last-update').textContent = timeString; +} + +function renderNodes(data) { + const container = document.getElementById('nodes-container'); + + if (!data.nodes || data.nodes.length === 0) { + container.innerHTML = '

Nessun nodo trovato

'; + return; + } + + container.innerHTML = data.nodes.map(node => createNodeCard(node)).join(''); +} + +function createNodeCard(node) { + const isOnline = node.online; + const statusClass = isOnline ? 'online' : 'offline'; + const statusText = isOnline ? '🟢 Online' : '🔴 Offline'; + + const lastCheck = new Date(node.last_check); + const timeAgo = getTimeAgo(lastCheck); + + return ` +
+
+
${escapeHtml(node.hostname)}
+
+
+ +
+
IP ZeroTier
+
${escapeHtml(node.ip)}
+
+ +
+
ID
+
${escapeHtml(node.id)}
+
+ +
+ ${statusText} +
+ +
+ Verificato ${timeAgo} +
+
+ `; +} + +function getTimeAgo(date) { + const now = new Date(); + const seconds = Math.floor((now - date) / 1000); + + if (seconds < 60) return 'proprio ora'; + if (seconds < 120) return 'un minuto fa'; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m fa`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h fa`; + + const days = Math.floor(hours / 24); + return `${days}d fa`; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Load network ID from localStorage if exists +document.addEventListener('DOMContentLoaded', () => { + const networkIdInput = document.getElementById('networkId'); + const saved = localStorage.getItem('zerotier_network_id'); + if (saved) { + networkIdInput.value = saved; + } + + // Salva network ID nel localStorage quando cambia + networkIdInput.addEventListener('change', (e) => { + if (e.target.value) { + localStorage.setItem('zerotier_network_id', e.target.value); + } + }); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..d422f0e --- /dev/null +++ b/static/style.css @@ -0,0 +1,304 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 20px; + color: #333; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +header { + text-align: center; + color: white; + margin-bottom: 40px; +} + +header h1 { + font-size: 2.5em; + margin-bottom: 5px; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +header .subtitle { + font-size: 1.1em; + opacity: 0.9; +} + +/* Config Section */ +.config-section { + background: white; + padding: 20px; + border-radius: 10px; + margin-bottom: 30px; + display: flex; + gap: 10px; + align-items: center; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +.error-banner { + background: #ffebee; + border-left: 4px solid #f44336; + color: #c62828; + padding: 15px 20px; + border-radius: 5px; + margin-bottom: 20px; + display: none; + font-weight: 500; +} + +.config-section label { + font-weight: 600; + min-width: 100px; +} + +.config-section input { + flex: 1; + padding: 10px 15px; + border: 2px solid #e0e0e0; + border-radius: 5px; + font-size: 1em; + transition: border-color 0.3s; +} + +.config-section input:focus { + outline: none; + border-color: #667eea; +} + +.config-section button { + padding: 10px 25px; + background: #667eea; + color: white; + border: none; + border-radius: 5px; + font-weight: 600; + cursor: pointer; + transition: background 0.3s; +} + +.config-section button:hover { + background: #5568d3; +} + +.status-dot { + width: 12px; + height: 12px; + border-radius: 50%; + animation: pulse 2s infinite; +} + +.status-dot.online { + background: #4caf50; +} + +.status-dot.offline { + background: #f44336; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Stats Section */ +.stats-section { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 40px; +} + +.stat-box { + background: white; + padding: 25px; + border-radius: 10px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); + text-align: center; +} + +.stat-label { + font-size: 0.9em; + color: #999; + margin-bottom: 10px; + font-weight: 500; +} + +.stat-value { + font-size: 2.5em; + font-weight: bold; + color: #667eea; +} + +/* Nodes Section */ +.nodes-section { + background: white; + padding: 30px; + border-radius: 10px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +.nodes-section h2 { + margin-bottom: 20px; + color: #333; +} + +.nodes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; +} + +.empty-state { + grid-column: 1 / -1; + text-align: center; + padding: 40px; + color: #999; + font-size: 1.1em; +} + +/* Node Card */ +.node-card { + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 20px; + transition: all 0.3s; + position: relative; + overflow: hidden; +} + +.node-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: #4caf50; +} + +.node-card.offline::before { + background: #f44336; +} + +.node-card.offline { + opacity: 0.7; + border-color: #ffcdd2; +} + +.node-card:hover { + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); + transform: translateY(-2px); +} + +.node-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 15px; +} + +.node-hostname { + font-weight: 700; + font-size: 1.2em; + color: #333; + word-break: break-word; +} + +.node-status-badge { + display: inline-block; + width: 24px; + height: 24px; + border-radius: 50%; + background: #4caf50; + position: relative; +} + +.node-status-badge.offline { + background: #f44336; +} + +.node-status-badge::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 10px; + height: 10px; + background: white; + border-radius: 50%; +} + +.node-info { + margin-bottom: 10px; +} + +.node-info-label { + font-size: 0.85em; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 3px; +} + +.node-info-value { + font-family: 'Monaco', 'Menlo', monospace; + font-size: 0.95em; + color: #333; + word-break: break-all; + background: #f5f5f5; + padding: 8px; + border-radius: 4px; +} + +.node-status-text { + font-size: 0.9em; + color: #4caf50; + font-weight: 600; +} + +.node-status-text.offline { + color: #f44336; +} + +.node-timestamp { + font-size: 0.75em; + color: #ccc; + margin-top: 10px; + text-align: right; +} + +/* Responsive */ +@media (max-width: 768px) { + header h1 { + font-size: 1.8em; + } + + .config-section { + flex-direction: column; + } + + .config-section label { + width: 100%; + } + + .config-section input, + .config-section button { + width: 100%; + } + + .nodes-grid { + grid-template-columns: 1fr; + } +}