commit efa02394dcc7fc4430f5e28fbc2b809584efebb2 Author: Roby Date: Sun Apr 26 01:36:09 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..071f847 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Ignora le cartelle temporanee di Python +__pycache__/ +*.pyc + +# Ignora il database SQLite (non vogliamo pubblicare i log e le password hashate!) +*.db +monitor.db +monitor.db-journal +monitor.db-shm +monitor.db-wal + +# Ignora i file di configurazione reali (pubblicherai solo i .example.json) +config.json +clients.json + +# Ignora i file dati scaricati in automatico (sono grandi e dinamici) +dmrid.dat +nxdn.csv +telemetry_cache.json + +# Ignora log di sistema eventuali +*.log + +# Ignora le configurazioni reali dell'agente remoto +*.ini +!*.example.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe34289 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# ๐Ÿ“ก Fleet Control Console (Server) + +๐ŸŒ *[Read in English](#english) | ๐Ÿ‡ฎ๐Ÿ‡น [Leggi in Italiano](#italiano)* + +--- + + +## ๐Ÿ‡ฌ๐Ÿ‡ง English + +**Fleet Control Console** is a professional, real-time command and control (C2) dashboard designed for amateur radio repeater networks (MMDVM). + +![Dashboard Screenshot](images/dashboard.png) + +### ๐Ÿค– Remote Agent +To monitor your remote nodes (Raspberry Pi), download the dedicated lightweight agent here: +`[Insert your Agent Repository URL here]` + +### โœจ Features +* **Zero-Latency Real-Time UI:** Powered by WebSockets (Socket.IO). +* **Web Push Notifications:** Instant alerts on desktop or mobile. +* **Centralized Telemetry & Service Management.** +* **Global Operations:** Switch profiles instantly. + +### ๐Ÿš€ Installation & Setup +1. Read `install.txt` to install system prerequisites (compilers). +2. `python3 -m venv venv && source venv/bin/activate` +3. `pip install -r requirements.txt` +4. Configure `config.json` and generate VAPID keys. +5. Run via Gunicorn: `gunicorn -k "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" -w 1 --bind 0.0.0.0:9000 app:app` + +--- + + +## ๐Ÿ‡ฎ๐Ÿ‡น Italiano + +**Fleet Control Console** รจ una dashboard di comando e controllo (C2) professionale in tempo reale per le reti di ripetitori radioamatoriali (MMDVM). + +![Schermata Dashboard](images/dashboard.png) + +### ๐Ÿค– Agente Remoto +Per monitorare i tuoi nodi remoti (Raspberry Pi), scarica l'agente dedicato qui: +`[Inserisci qui l'URL del tuo Repository Agent]` + +### โœจ Funzionalitร  +* **Interfaccia Real-Time a Latenza Zero** tramite WebSockets. +* **Notifiche Push Web** per allarmi critici. +* **Telemetria Centralizzata e Gestione Servizi.** +* **Operazioni Globali** su tutta la rete. + +### ๐Ÿš€ Installazione +1. Leggi `install.txt` per i requisiti di sistema (compilatori Linux). +2. `python3 -m venv venv && source venv/bin/activate` +3. `pip install -r requirements.txt` +4. Configura `config.json` con credenziali MQTT e chiavi VAPID. +5. Avvia con Gunicorn. + +--- +*Created by IV3JDV @ ARIFVG - 2026* diff --git a/app.py b/app.py new file mode 100644 index 0000000..6e5b7bb --- /dev/null +++ b/app.py @@ -0,0 +1,784 @@ +from flask import Flask, render_template, request, session, jsonify, send_from_directory +from paho.mqtt import client as mqtt_client +from werkzeug.security import generate_password_hash, check_password_hash +import json +import os +import sqlite3 +import urllib.request +import threading +import time +import logging +from pywebpush import webpush, WebPushException +from logging.handlers import RotatingFileHandler +from flask_socketio import SocketIO, emit + +# --- LOGGING CONFIGURATION --- +logging.basicConfig( + handlers=[ + RotatingFileHandler('/opt/web-control/fleet_console.log', maxBytes=10000000, backupCount=3), + logging.StreamHandler() + ], + level=logging.INFO, + format='[%(asctime)s] %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger("FleetHub") +# Silence HTTP request spam (GET /api/states 200 OK) +logging.getLogger('werkzeug').setLevel(logging.ERROR) + +# --- PATHS --- +DB_PATH = '/opt/web-control/monitor.db' +CACHE_FILE = '/opt/web-control/telemetry_cache.json' +CONFIG_PATH = '/opt/web-control/config.json' +DMR_IDS_PATH = '/opt/web-control/dmrid.dat' +NXDN_IDS_PATH = '/opt/web-control/nxdn.csv' +CLIENTS_PATH = '/opt/web-control/clients.json' + +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + c.execute('PRAGMA journal_mode=WAL;') # <-- MAGIC: Enable simultaneous read/write! + + c.execute('''CREATE TABLE IF NOT EXISTS radio_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, client_id TEXT, + source_id TEXT, target TEXT, slot INTEGER, duration REAL, ber REAL, loss REAL)''') + try: + c.execute("ALTER TABLE radio_logs ADD COLUMN protocol TEXT DEFAULT 'DMR'") + except: pass + + try: + c.execute("ALTER TABLE radio_logs ADD COLUMN source_ext TEXT DEFAULT ''") + except: pass + + c.execute('''CREATE TABLE IF NOT EXISTS users + (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT, + role TEXT, allowed_nodes TEXT)''') + + c.execute('''CREATE TABLE IF NOT EXISTS push_subscriptions + (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, subscription TEXT UNIQUE)''') + + c.execute('''CREATE TABLE IF NOT EXISTS audit_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, username TEXT, + client_id TEXT, command TEXT)''') + + c.execute("SELECT COUNT(*) FROM users") + if c.fetchone()[0] == 0: + # Default value if missing config file + def_user = "admin" + def_pass = "admin123" + + # Try read config.json + try: + with open(CONFIG_PATH, 'r') as f: + cfg = json.load(f) + def_user = cfg.get("web_admin", {}).get("default_user", "admin") + def_pass = cfg.get("web_admin", {}).get("default_pass", "admin123") + except Exception: + pass + + h = generate_password_hash(def_pass) + c.execute("INSERT INTO users (username, password_hash, role, allowed_nodes) VALUES (?,?,?,?)", + (def_user, h, 'admin', 'all')) + logger.info(f">>> DEFAULT USER CREATED - User: {def_user} | Pass: {def_pass} <<<") + + conn.commit() + conn.close() + +init_db() + +# --- ID DATABASE LOADING --- +user_db = {} +nxdn_db = {} + +def load_ids(): + global user_db, nxdn_db + user_db.clear() + nxdn_db.clear() + + if os.path.exists(DMR_IDS_PATH): + with open(DMR_IDS_PATH, 'r', encoding='utf-8', errors='ignore') as f: + for l in f: + sep = '\t' if '\t' in l else (',' if ',' in l else ';') + p = l.strip().split(sep) + if len(p) >= 2 and p[0].strip().isdigit(): + user_db[p[0].strip()] = p[1].strip() + + if os.path.exists(NXDN_IDS_PATH): + with open(NXDN_IDS_PATH, 'r', encoding='utf-8', errors='ignore') as f: + for l in f: + sep = '\t' if '\t' in l else (',' if ',' in l else ';') + p = l.strip().split(sep) + if len(p) >= 2 and p[0].strip().isdigit(): + nxdn_db[p[0].strip()] = p[1].strip() + +load_ids() + +def get_call(id, proto="DMR"): + sid = str(id) + if proto == "NXDN": return nxdn_db.get(sid, sid) + return user_db.get(sid, sid) + +def save_cache(data): + with open(CACHE_FILE, 'w') as f: json.dump(data, f) + socketio.emit('dati_aggiornati') + +def save_to_sqlite(client_id, data, protocol="DMR"): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("INSERT INTO radio_logs (timestamp, client_id, protocol, source_id, target, slot, duration, ber, source_ext) VALUES (datetime('now', 'localtime'), ?, ?, ?, ?, ?, ?, ?, ?)", + (client_id, protocol, str(data.get('source_id', '---')), str(data.get('destination_id', '---')), data.get('slot', 0), round(data.get('duration', 0), 1), round(data.get('ber', 0), 2), str(data.get('source_ext', '')))) + conn.commit() + conn.close() + socketio.emit('dati_aggiornati') + +app = Flask(__name__) +socketio = SocketIO(app, cors_allowed_origins="*") +app.secret_key = 'ari_fvg_secret_ultra_secure' +client_states = {} +device_configs = {} +client_telemetry = {} +last_notified_errors = {} +device_health = {} +last_seen_reflector = {} +network_mapping = {} + +if os.path.exists(CACHE_FILE): + try: + with open(CACHE_FILE, 'r') as f: client_telemetry = json.load(f) + except: client_telemetry = {} + +active_calls = {} +with open(CONFIG_PATH) as f: config = json.load(f) + +# --- MQTT CALLBACKS --- +def on_connect(client, userdata, flags, reason_code, properties=None): + if reason_code == 0: + logger.info("โœ… Successfully connected to MQTT Broker! Subscribing to topics...") + client.subscribe([ + ("servizi/+/stat", 0), + ("dmr-gateway/+/json", 0), + ("devices/+/services", 0), + ("nxdn-gateway/+/json", 0), + ("ysf-gateway/+/json", 0), + ("p25-gateway/+/json", 0), + ("dstar-gateway/+/json", 0), + ("mmdvm/+/json", 0), + ("devices/#", 0), + ("data/#", 0) + ]) + else: + logger.error(f"โŒ MQTT Connection Error. Reason code: {reason_code}") + +def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None): + logger.warning(f"โš ๏ธ MQTT Disconnection detected! Reason code: {reason_code}. Attempting automatic reconnection...") + +def on_message(client, userdata, msg): + try: + topic = msg.topic + payload = msg.payload.decode().strip() + parts = topic.split('/') + if len(parts) < 2: return + cid = parts[1].lower() + + # --- CAPTURE FULL CONFIGURATIONS --- + if parts[0] == 'data' and len(parts) >= 4 and parts[3] == 'full_config': + cid_conf = parts[1].lower() + svc_name = parts[2].lower() + if cid_conf not in device_configs: + device_configs[cid_conf] = {} + try: + device_configs[cid_conf][svc_name] = json.loads(payload) + logger.debug(f"Configuration saved for {cid_conf} -> {svc_name}") + except Exception as e: + logger.error(f"Error parsing config JSON: {e}") + + # --- NODE AND SERVICE STATE MANAGEMENT --- + elif parts[0] == 'servizi': + client_states[cid] = payload + socketio.emit('dati_aggiornati') # <--- WEBSOCKET + + # --- PUSH TRIGGER: NODE STATE --- + if payload.upper() == 'OFFLINE': + if last_notified_errors.get(f"{cid}_NODE") != 'OFFLINE': + broadcast_push_notification(f"๐Ÿ’€ NODE OFFLINE: {cid.upper()}", "Connection lost with broker.") + last_notified_errors[f"{cid}_NODE"] = 'OFFLINE' + elif payload.upper() == 'ONLINE': + if last_notified_errors.get(f"{cid}_NODE") == 'OFFLINE': + broadcast_push_notification(f"๐ŸŒค๏ธ NODE ONLINE: {cid.upper()}", "Node is back online.") + del last_notified_errors[f"{cid}_NODE"] + + if payload.upper() not in ['OFF', 'OFFLINE', '']: + tel = client_telemetry.get(cid, {}) + if isinstance(tel, dict) and '๐Ÿ”„' in str(tel.get('ts1', '')): + client_telemetry[cid] = {"ts1": "Waiting...", "ts2": "Waiting...", "alt": ""} + save_cache(client_telemetry) + + # --- DEVICE HEALTH MANAGEMENT --- + elif parts[0] == 'devices' and len(parts) >= 3 and parts[2] == 'services': + try: + data = json.loads(payload) + device_health[cid] = { + "cpu": round(data.get("cpu_usage_percent", 0), 1), + "temp": round(data.get("cpu_temp", 0), 1), + "ram": round(data.get("memory_usage_percent", 0), 1), + "disk": round(data.get("disk_usage_percent", 0), 1), + "processes": data.get("processes", {}), + "files": data.get("files", data.get("config_files", [])), + "profiles": data.get("profiles", {"A": "PROFILE A", "B": "PROFILE B"}) + } + socketio.emit('dati_aggiornati') # <--- WEBSOCKET + + # --- PUSH TRIGGER: SERVICE ERRORS --- + processes = data.get("processes", {}) + for svc_name, svc_status in processes.items(): + status_key = f"{cid}_{svc_name}" + s_lower = svc_status.lower() + if s_lower in ["error", "stopped", "failed"]: + if last_notified_errors.get(status_key) != s_lower: + msg_err = f"Service {svc_name} KO ({svc_status})" + if s_lower == "error": msg_err += " - Auto-healing failed! โš ๏ธ" + broadcast_push_notification(f"๐Ÿšจ ALARM: {cid.upper()}", msg_err) + last_notified_errors[status_key] = s_lower + elif s_lower == "online" and status_key in last_notified_errors: + broadcast_push_notification(f"โœ… RESTORED: {cid.upper()}", f"Service {svc_name} back ONLINE.") + del last_notified_errors[status_key] + # ----------------------------------------- + + except Exception as e: + logger.error(f"Error parsing health data: {e}") + + # --- DMR GATEWAY MANAGEMENT --- + elif len(parts) >= 4 and parts[0] == 'data' and parts[2].lower() == 'dmrgateway' and (parts[3].upper().startswith('NETWORK') or parts[3].upper().startswith('DMR NETWORK')): + try: + cid = parts[1].lower() + data = json.loads(payload) + + if cid not in network_mapping: + network_mapping[cid] = {"ts1": "", "ts2": ""} + + if str(data.get("Enabled")) == "1": + net_name = data.get("Name", "Net").upper() + is_ts1 = False + is_ts2 = False + + keys_to_check = ["PassAllTG", "PassAllPC", "TGRewrite", "PCRewrite", "TypeRewrite", "SrcRewrite"] + for k in keys_to_check: + val = str(data.get(k, "")).strip() + if val.startswith("1"): is_ts1 = True + if val.startswith("2"): is_ts2 = True + + if is_ts1: network_mapping[cid]["ts1"] = net_name + if is_ts2: network_mapping[cid]["ts2"] = net_name + socketio.emit('dati_aggiornati') # <--- WEBSOCKET + + except Exception as e: + logger.error(f"Error parsing DMRGateway for {cid}: {e}") + + # --- OTHER GATEWAYS MANAGEMENT --- + elif parts[0] in ['dmr-gateway', 'nxdn-gateway', 'ysf-gateway', 'p25-gateway', 'dstar-gateway']: + data = json.loads(payload) + proto = "DMR" + if "nxdn" in parts[0]: proto = "NXDN" + elif "ysf" in parts[0]: proto = "YSF" + elif "p25" in parts[0]: proto = "P25" + elif "dstar" in parts[0]: proto = "D-STAR" + + m = "" + if 'status' in data: + m = data['status'].get('message', '') + elif 'link' in data: + l = data['link'] + dest = str(l.get('reflector') or l.get('talkgroup') or '---').strip() + action = l.get('action') + if action == 'linking': last_seen_reflector[f"{cid}_{proto}"] = dest + elif action == 'unlinking': last_seen_reflector[f"{cid}_{proto}"] = "---" + m = f"{'Link' if action=='linking' else 'Unlinked'} {dest}" + + if m: save_to_sqlite(cid, {'source_id': "๐ŸŒ " + m, 'destination_id': 'NET'}, protocol=proto) + + # --- MMDVM AND TRAFFIC MANAGEMENT --- + elif parts[0] == 'mmdvm': + data = json.loads(payload) + if cid not in active_calls: active_calls[cid] = {} + if cid not in client_telemetry or not isinstance(client_telemetry.get(cid), dict): + client_telemetry[cid] = {"ts1": "Waiting...", "ts2": "Waiting...", "alt": "", "idle": True} + + if 'MMDVM' in data and data['MMDVM'].get('mode') == 'idle': + client_telemetry[cid]["idle"] = True + save_cache(client_telemetry) + return + + client_telemetry[cid]["idle"] = False + + if 'DMR' in data: + d = data['DMR'] + act = d.get('action') + sk = f"ts{d.get('slot', 1)}" + if act in ['start', 'late_entry']: + src = get_call(d.get('source_id')) + dst = str(d.get('destination_id')) + active_calls[cid][sk] = {'src': src, 'dst': dst} + client_telemetry[cid]["alt"] = "" + client_telemetry[cid][sk] = f"๐ŸŽ™๏ธ {src} โž” TG {dst}" + socketio.emit('dati_aggiornati') # <--- WEBSOCKET + elif act in ['end', 'lost']: + info = active_calls[cid].get(sk, {'src': '---', 'dst': '---'}) + d['source_id'], d['destination_id'] = info['src'], info['dst'] + save_to_sqlite(cid, d, protocol="DMR") + client_telemetry[cid][sk] = f"{'โœ…' if act=='end' else 'โš ๏ธ'} {info['src']}" + save_cache(client_telemetry) + if sk in active_calls[cid]: del active_calls[cid][sk] + + else: + for k, ico, name in [('NXDN','๐ŸŸข','NXDN'),('YSF','๐ŸŸฃ','YSF'),('P25','๐ŸŸ ','P25'),('D-Star','๐Ÿ”ต','D-STAR')]: + if k in data: + p = data[k] + act = p.get('action') + + if act == 'start': + if k == 'NXDN': src = get_call(p.get('source_id', '---'), 'NXDN') + elif k == 'P25': src = get_call(p.get('source_id', '---'), 'DMR') + else: src = str(p.get('Callsign', p.get('source_cs', p.get('source_info', p.get('source_id', '---'))))) + + t_list = [p.get('reflector'), p.get('destination_cs'), p.get('destination_id')] + current_target = next((str(x).strip() for x in t_list if x and str(x).strip() not in ['', '---', '0', 'CQCQCQ']), None) + + if not current_target or current_target == cid.upper(): + target = last_seen_reflector.get(f"{cid}_{name}", "---") + else: + target = current_target + + active_calls[cid][k] = {'src': src, 'dst': target, 'ext': str(p.get('source_ext', ''))} + client_telemetry[cid].update({"ts1":"","ts2":"","alt": f"{ico} {name}: {src} โž” {target}"}) + socketio.emit('dati_aggiornati') # <--- WEBSOCKET + + elif act in ['end', 'lost']: + info = active_calls[cid].get(k, {'src': '---', 'dst': '---', 'ext': ''}) + p.update({'source_id': info['src'], 'destination_id': info['dst'], 'source_ext': info['ext']}) + save_to_sqlite(cid, p, protocol=name) + client_telemetry[cid]["alt"] = f"{'โœ…' if act=='end' else 'โš ๏ธ'} {name}: {info['src']}" + save_cache(client_telemetry) + if k in active_calls[cid]: del active_calls[cid][k] + except Exception as e: + logger.error(f"MQTT MSG ERROR: {e}") + +# --- MQTT CLIENT INITIALIZATION --- +mqtt_backend = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2, "flask_backend") +mqtt_backend.username_pw_set(config['mqtt']['user'], config['mqtt']['password']) +mqtt_backend.on_connect = on_connect +mqtt_backend.on_disconnect = on_disconnect +mqtt_backend.on_message = on_message +mqtt_backend.connect(config['mqtt']['broker'], config['mqtt']['port']) +mqtt_backend.loop_start() + +@app.route('/') +def index(): return render_template('index.html') + +@app.route('/api/clients') +def get_clients(): + if os.path.exists(CLIENTS_PATH): + with open(CLIENTS_PATH, 'r') as f: return jsonify(json.load(f)) + return jsonify([]) + +@app.route('/api/logs') +def get_logs(): + conn = sqlite3.connect(DB_PATH, timeout=10) + c = conn.cursor() + c.execute("SELECT timestamp, client_id, protocol, source_id, target, slot, duration, ber, source_ext FROM radio_logs ORDER BY id DESC LIMIT 60") + logs = c.fetchall() + conn.close() + return jsonify(logs) + +@app.route('/api/states', methods=['GET']) +def get_states(): + return jsonify({ + "states": client_states, + "telemetry": client_telemetry, + "health": device_health, + "networks": network_mapping + }) + +@app.route('/api/stats') +def get_stats(): + # Leggiamo il parametro 'node' (di default 'all') + node = request.args.get('node', 'all').lower() + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # Prepariamo il filtro aggiuntivo se รจ stato selezionato un nodo + node_filter = "" + params = [] + if node != 'all': + node_filter = " AND LOWER(client_id) = ?" + params.append(node) + + # 1. Top 5 TalkGroups di OGGI + c.execute(f"""SELECT target, COUNT(*) as cnt FROM radio_logs + WHERE target NOT IN ('---', 'NET', '') + AND date(timestamp) = date('now', 'localtime'){node_filter} + GROUP BY target ORDER BY cnt DESC LIMIT 5""", params) + top_tgs = [{"target": row[0], "count": row[1]} for row in c.fetchall()] + + # 2. Top 5 Callsign di OGGI + c.execute(f"""SELECT source_id, COUNT(*) as cnt FROM radio_logs + WHERE source_id NOT LIKE '๐ŸŒ%' + AND date(timestamp) = date('now', 'localtime'){node_filter} + GROUP BY source_id ORDER BY cnt DESC LIMIT 5""", params) + top_calls = [{"call": row[0], "count": row[1]} for row in c.fetchall()] + + # 3. Tempo medio dei transiti di OGGI + c.execute(f"""SELECT AVG(duration) FROM radio_logs + WHERE duration > 0.5 + AND date(timestamp) = date('now', 'localtime'){node_filter}""", params) + avg_dur = c.fetchone()[0] + avg_dur = round(avg_dur, 1) if avg_dur else 0 + + # 4. Totale transiti di OGGI + c.execute(f"SELECT COUNT(*) FROM radio_logs WHERE date(timestamp) = date('now', 'localtime'){node_filter}", params) + today_tx = c.fetchone()[0] + + conn.close() + return jsonify({ + "top_tgs": top_tgs, + "top_calls": top_calls, + "avg_duration": avg_dur, + "today_tx": today_tx + }) + +@app.route('/api/service_control', methods=['POST']) +def service_control(): + if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 + d = request.json + cid = d.get('clientId').lower() + action = d.get('action') + service = d.get('service') + mqtt_backend.publish(f"devices/{cid}/control", f"{action}:{service}") + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("INSERT INTO audit_logs (timestamp, username, client_id, command) VALUES (datetime('now','localtime'), ?, ?, ?)", + (session.get('user'), cid, f"SVC_{action.upper()}_{service}")) + conn.commit() + conn.close() + return jsonify({"success": True}) + +@app.route('/api/login', methods=['POST']) +def login(): + d = request.json + username, password = d.get('user'), d.get('pass') + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT * FROM users WHERE username = ?", (username,)) + user = c.fetchone() + conn.close() + if user and check_password_hash(user['password_hash'], password): + session['logged_in'] = True + session['user'] = user['username'] + session['role'] = user['role'] + session['allowed_nodes'] = user['allowed_nodes'] + return jsonify({"success": True, "role": user['role'], "allowed_nodes": user['allowed_nodes']}) + return jsonify({"success": False}), 401 + +@app.route('/api/command', methods=['POST']) +def cmd(): + if not session.get('logged_in'): return jsonify({"success": False, "error": "Not authenticated"}), 403 + d = request.json + cid = d['clientId'].lower() + cmd_type = d['type'] + username = session.get('user') + role = session.get('role') + allowed = session.get('allowed_nodes', '') + is_allowed = (role == 'admin' or allowed == 'all' or cid in [x.strip() for x in allowed.split(',')]) + if cmd_type == 'REBOOT' and role != 'admin': + return jsonify({"success": False, "error": "Only Admins can reboot."}), 403 + if is_allowed: + mqtt_backend.publish(f"servizi/{cid}/cmnd", cmd_type) + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("INSERT INTO audit_logs (timestamp, username, client_id, command) VALUES (datetime('now','localtime'), ?, ?, ?)", + (username, cid, cmd_type)) + conn.commit() + conn.close() + client_telemetry[cid] = {"ts1": "๐Ÿ”„ Sent...", "ts2": "๐Ÿ”„ Sent...", "alt": ""} + return jsonify({"success": True}) + return jsonify({"success": False, "error": "You do not have permission for this node."}), 403 + +@app.route('/api/update_nodes', methods=['POST']) +def update_nodes(): + if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 + mqtt_backend.publish("devices/control/request", "update") + return jsonify({"success": True}) + + # Mandiamo il comando "update" direttamente nel topic privato di ciascun nodo + for client in clients_list: + cid = client['id'].lower() + mqtt_backend.publish(f"devices/{cid}/control", "update", qos=1) + + logger.info("๐Ÿ“ข Inviato comando REQ CONFIG diretto a tutti i nodi della flotta.") + return jsonify({"success": True}) + +@app.route('/api/users', methods=['GET']) +def get_users(): + if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT id, username, role, allowed_nodes FROM users") + users = [dict(row) for row in c.fetchall()] + conn.close() + return jsonify(users) + +@app.route('/api/users', methods=['POST']) +def add_user(): + if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 + d = request.json + username = d.get('username') + password = d.get('password') + role = d.get('role', 'operator') + allowed = d.get('allowed_nodes', '') + if not username or not password: + return jsonify({"success": False, "error": "Missing data"}) + h = generate_password_hash(password) + try: + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("INSERT INTO users (username, password_hash, role, allowed_nodes) VALUES (?,?,?,?)", + (username, h, role, allowed)) + conn.commit() + conn.close() + return jsonify({"success": True}) + except sqlite3.IntegrityError: + return jsonify({"success": False, "error": "Username already exists"}) + +@app.route('/api/users/', methods=['DELETE']) +def delete_user(user_id): + if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("SELECT username FROM users WHERE id = ?", (user_id,)) + u = c.fetchone() + if u and u[0] == session.get('user'): + conn.close() + return jsonify({"success": False, "error": "You cannot delete yourself!"}) + c.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + return jsonify({"success": True}) + +@app.route('/api/users/', methods=['PUT']) +def update_user(user_id): + if session.get('role') != 'admin': + return jsonify({"error": "Unauthorized"}), 403 + + data = request.json + role = data.get('role', 'operator') + allowed = data.get('allowed_nodes', 'all') + password = data.get('password') + + try: + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + if password and password.strip() != "": + h = generate_password_hash(password) + c.execute("UPDATE users SET password_hash=?, role=?, allowed_nodes=? WHERE id=?", + (h, role, allowed, user_id)) + else: + c.execute("UPDATE users SET role=?, allowed_nodes=? WHERE id=?", + (role, allowed, user_id)) + + conn.commit() + conn.close() + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}) + +@app.route('/api/change_password', methods=['POST']) +def change_password(): + if not session.get('logged_in'): + return jsonify({"success": False, "error": "Not authenticated"}), 403 + d = request.json + new_pass = d.get('new_password') + user_to_change = d.get('username') + if session.get('role') != 'admin' and session.get('user') != user_to_change: + return jsonify({"success": False, "error": "Unauthorized"}), 403 + if not new_pass: + return jsonify({"success": False, "error": "Password cannot be empty"}), 400 + h = generate_password_hash(new_pass) + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("UPDATE users SET password_hash = ? WHERE username = ?", (h, user_to_change)) + conn.commit() + conn.close() + return jsonify({"success": True}) + +@app.route('/api/global_command', methods=['POST']) +def global_cmd(): + if session.get('role') != 'admin': + return jsonify({"success": False, "error": "Admin action only!"}), 403 + d = request.json + cmd_type = d.get('type') + clients_list = [] + if os.path.exists(CLIENTS_PATH): + with open(CLIENTS_PATH, 'r') as f: + clients_list = json.load(f) + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + for client in clients_list: + cid = client['id'].lower() + mqtt_backend.publish(f"servizi/{cid}/cmnd", cmd_type) + c.execute("INSERT INTO audit_logs (timestamp, username, client_id, command) VALUES (datetime('now','localtime'), ?, ?, ?)", + (session.get('user'), cid, f"GLOBAL_OVERRIDE_{cmd_type}")) + conn.commit() + conn.close() + return jsonify({"success": True}) + +def auto_update_ids(): + while True: + try: + with open(CONFIG_PATH, 'r') as f: + current_cfg = json.load(f) + target_time = current_cfg.get("update_schedule", "03:00") + urls = current_cfg.get("id_urls", { + "dmr": "https://radioid.net/static/dmrid.dat", + "nxdn": "https://radioid.net/static/nxdn.csv" + }) + now = time.strftime("%H:%M") + if now == target_time: + logger.info(f">>> [AUTO-UPDATE] Scheduled time reached ({now}). Downloading...") + urllib.request.urlretrieve(urls["dmr"], DMR_IDS_PATH) + urllib.request.urlretrieve(urls["nxdn"], NXDN_IDS_PATH) + load_ids() + logger.info(f">>> [AUTO-UPDATE] Completed successfully.") + time.sleep(65) + except Exception as e: + logger.error(f">>> [AUTO-UPDATE] Error: {e}") + time.sleep(30) + +@app.route('/api/ui_config', methods=['GET']) +def get_ui_config(): + try: + with open(CONFIG_PATH, 'r') as f: + cfg = json.load(f) + ui_cfg = cfg.get("ui", { + "profileA_Name": "PROFILE A", + "profileA_Color": "#3498db", + "profileB_Name": "PROFILE B", + "profileB_Color": "#9b59b6" + }) + return jsonify(ui_cfg) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@app.route('/api/config', methods=['GET']) +def get_config_api(): + if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 + with open(CONFIG_PATH, 'r') as f: + cfg = json.load(f) + return jsonify({ + "update_schedule": cfg.get("update_schedule", "03:00"), + "url_dmr": cfg.get("id_urls", {}).get("dmr", ""), + "url_nxdn": cfg.get("id_urls", {}).get("nxdn", "") + }) + +@app.route('/api/config', methods=['POST']) +def save_config_api(): + if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 + new_data = request.json + with open(CONFIG_PATH, 'r') as f: + cfg = json.load(f) + cfg["update_schedule"] = new_data.get("update_schedule", "03:00") + if "id_urls" not in cfg: cfg["id_urls"] = {} + cfg["id_urls"]["dmr"] = new_data.get("url_dmr", "") + cfg["id_urls"]["nxdn"] = new_data.get("url_nxdn", "") + with open(CONFIG_PATH, 'w') as f: + json.dump(cfg, f, indent=4) + return jsonify({"success": True}) + +@app.route('/api/config_file//', methods=['GET']) +def get_config_file(cid, service): + if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 + cid = cid.lower() + service = service.lower() + config_data = device_configs.get(cid, {}).get(service) + + if not config_data: + return jsonify({"error": "Configuration not received yet. Wait or send an UPDATE command."}), 404 + return jsonify({"success": True, "data": config_data}) + +@app.route('/api/config_file', methods=['POST']) +def save_config_file(): + if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 + d = request.json + cid = d.get('clientId').lower() + service = d.get('service').lower() + new_config = d.get('config_data') + topic_set = f"devices/{cid}/config_set/{service}" + mqtt_backend.publish(topic_set, json.dumps(new_config)) + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("INSERT INTO audit_logs (timestamp, username, client_id, command) VALUES (datetime('now','localtime'), ?, ?, ?)", + (session.get('user'), cid, f"EDIT_CONFIG_{service.upper()}")) + conn.commit() + conn.close() + return jsonify({"success": True}) + +@app.route('/manifest.json') +def serve_manifest(): + return send_from_directory('.', 'manifest.json') + +@app.route('/sw.js') +def serve_sw(): + return send_from_directory('.', 'sw.js') + +@app.route('/icon-512.png') +def serve_icon(): + return send_from_directory('.', 'icon-512.png') + +def broadcast_push_notification(title, body): + wp_config = config.get('webpush') + if not wp_config: return + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("SELECT id, subscription FROM push_subscriptions") + subs = c.fetchall() + + for sub_id, sub_json in subs: + try: + webpush( + subscription_info=json.loads(sub_json), + data=json.dumps({"title": title, "body": body}), + vapid_private_key=wp_config['vapid_private_key'], + vapid_claims={"sub": wp_config['vapid_claim_email']} + ) + except WebPushException as ex: + if ex.response and ex.response.status_code == 410: + c.execute("DELETE FROM push_subscriptions WHERE id = ?", (sub_id,)) + conn.commit() + except Exception as e: + logger.error(f"Generic Push Error: {e}") + conn.close() + +@app.route('/api/vapid_public_key') +def get_vapid_key(): + return jsonify({"public_key": config.get('webpush', {}).get('vapid_public_key', '')}) + +@app.route('/api/subscribe', methods=['POST']) +def subscribe_push(): + if not session.get('logged_in'): return jsonify({"error": "Unauthorized"}), 403 + sub_data = request.json + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("INSERT OR IGNORE INTO push_subscriptions (username, subscription) VALUES (?, ?)", + (session.get('user'), json.dumps(sub_data))) + conn.commit() + conn.close() + return jsonify({"success": True}) + +threading.Thread(target=auto_update_ids, daemon=True).start() + +if __name__ == '__main__': + socketio.run(app, host='0.0.0.0', port=9000) diff --git a/clients.json.example b/clients.json.example new file mode 100644 index 0000000..c596588 --- /dev/null +++ b/clients.json.example @@ -0,0 +1,6 @@ +[ + {"_comment": "Enter each repeater/node you want to control. The ID must match the MQTT topic"}, + { "id": "repeater1", "name": "NAME 1" }, + { "id": "repeater2", "name": "NAME 2" }, + { "id": "repeater3", "name": "NAME 3" } +] diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..510e0fd --- /dev/null +++ b/config.json.example @@ -0,0 +1,28 @@ +{ + "mqtt": { + "broker": "your_mqtt_broker_address", + "port": 1883, + "user": "your_username", + "password": "your_password" + }, + "web_admin": { + "default_user": "admin", + "default_pass": "admin123" + }, + "webpush": { + "vapid_public_key": "INSERT_GENERATED_PUBLIC_KEY_HERE", + "vapid_private_key": "INSERT_GENERATED_PRIVATE_KEY_HERE", + "vapid_claim_email": "mailto:your@email.com" + }, + "ui": { + "profileA_Name": "PROFILE A", + "profileA_Color": "#3b82f6", + "profileB_Name": "PROFILE B", + "profileB_Color": "#eab308" + }, + "update_schedule": "03:00", + "id_urls": { + "dmr": "https://radioid.net/static/dmrid.dat", + "nxdn": "https://radioid.net/static/nxdn.csv" + } +} diff --git a/icon-512.png b/icon-512.png new file mode 100644 index 0000000..5bb7d67 Binary files /dev/null and b/icon-512.png differ diff --git a/images/dashboard.png b/images/dashboard.png new file mode 100644 index 0000000..6f85f7b Binary files /dev/null and b/images/dashboard.png differ diff --git a/install.txt b/install.txt new file mode 100644 index 0000000..642232f --- /dev/null +++ b/install.txt @@ -0,0 +1,106 @@ +============================================================ + INSTALLATION GUIDE - FLEET CONTROL CONSOLE (SERVER) +============================================================ + +------------------------------------------------------------ +1. PRE-REQUISITES (CRITICAL) +------------------------------------------------------------ +Before installing Python dependencies, you must install +system compilers and development libraries. + +Debian/Ubuntu: + sudo apt update + sudo apt install build-essential python3-dev libssl-dev libffi-dev + +Upgrade base pip tools: + pip install --upgrade pip setuptools wheel + +Create a virtual environment (Recommended): + python3 -m venv venv + source venv/bin/activate + pip install -r requirements.txt + +------------------------------------------------------------ +2. SERVER SETUP (CENTRAL HUB) +------------------------------------------------------------ +The server handles the web interface and user permissions. + +Steps: +1. Configure 'config.json' using 'config.example.json'. +2. Enter MQTT credentials and VAPID keys. +3. Define repeaters in the 'clients.json' file. +4. Install production WSGI server packages (if not in requirements): + pip install gunicorn gevent gevent-websocket +5. Start the production server: + gunicorn -k "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" -w 1 --bind 0.0.0.0:9000 app:app + +------------------------------------------------------------ +3. GENERATING VAPID KEYS (PUSH NOTIFICATIONS) +------------------------------------------------------------ +โš ๏ธ WARNING: Web Push Notifications strictly require the +dashboard to be accessed via a secure HTTPS connection. +They will NOT work over standard HTTP. + +1. Go to https://vapidkeys.com/ and generate the keys. +2. Copy 'Public Key' and 'Private Key' into 'config.json'. +3. Set 'vapid_claim_email' (e.g., "mailto:your@email.com"). + +------------------------------------------------------------ +4. RUNNING AS A SERVICE (SYSTEMD) +------------------------------------------------------------ +Configuration: +1. Copy .service file to '/etc/systemd/system/': + sudo cp fleet-console.service /etc/systemd/system/ +2. Reload systemd: sudo systemctl daemon-reload +3. Enable on boot: sudo systemctl enable fleet-console +4. Start service: sudo systemctl start fleet-console + +============================================================ + GUIDA ALL'INSTALLAZIONE - SERVER +============================================================ + +------------------------------------------------------------ +1. REQUISITI PRELIMINARI (CRITICI) +------------------------------------------------------------ +Prima di installare le dipendenze Python, รจ necessario +installare i compilatori di sistema. Senza questi, +l'installazione di 'gevent' fallirร  su VPS vergini. + +Esegui su Debian/Ubuntu: + sudo apt update + sudo apt install build-essential python3-dev libssl-dev libffi-dev + +Aggiorna gli strumenti di base di pip: + pip install --upgrade pip setuptools wheel + +Crea un ambiente virtuale (consigliato): + python3 -m venv venv + source venv/bin/activate + pip install -r requirements.txt + +------------------------------------------------------------ +2. SETUP DEL SERVER +------------------------------------------------------------ +Passaggi: +1. Configura 'config.json' partendo da 'config.example.json'. +2. Inserisci credenziali MQTT e chiavi VAPID. +3. Definisci i ripetitori in 'clients.json'. +4. Avvia il server di produzione: + gunicorn -k "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" -w 1 --bind 0.0.0.0:9000 app:app + +------------------------------------------------------------ +3. GENERAZIONE CHIAVI VAPID +------------------------------------------------------------ +โš ๏ธ ATTENZIONE: Le notifiche push richiedono HTTPS. + +1. Vai su https://vapidkeys.com/ e genera le chiavi. +2. Copia 'Public Key' e 'Private Key' nel 'config.json'. +3. Imposta 'vapid_claim_email' (es. "mailto:tua@email.com"). + +------------------------------------------------------------ +4. ESECUZIONE COME SERVIZIO +------------------------------------------------------------ +1. sudo cp fleet-console.service /etc/systemd/system/ +2. sudo systemctl daemon-reload +3. sudo systemctl enable fleet-console +4. sudo systemctl start fleet-console diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..74eef20 --- /dev/null +++ b/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "Fleet Control Console", + "short_name": "Fleet C2", + "start_url": "/", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#3b82f6", + "icons": [ + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/requirements-server.txt b/requirements-server.txt new file mode 100644 index 0000000..a5289ca --- /dev/null +++ b/requirements-server.txt @@ -0,0 +1,8 @@ +Flask +Flask-SocketIO +Werkzeug +pywebpush +paho-mqtt +gunicorn +gevent +gevent-websocket diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..79d751a --- /dev/null +++ b/sw.js @@ -0,0 +1,63 @@ +const CACHE_NAME = 'fleet-c2-v2'; // Incrementiamo la versione +const urlsToCache = [ + '/', + '/manifest.json', + '/icon-512.png' +]; + +// --- 1. GESTIONE CACHE (Il tuo codice originale) --- +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(urlsToCache)) + ); +}); + +self.addEventListener('fetch', event => { + event.respondWith( + fetch(event.request).catch(() => caches.match(event.request)) + ); +}); + +// --- 2. GESTIONE NOTIFICHE PUSH (Il nuovo codice) --- +self.addEventListener('push', function(event) { + console.log('[Service Worker] Notifica Push ricevuta.'); + + let data = { title: 'Fleet Alert', body: 'Nuovo messaggio dal sistema.' }; + + if (event.data) { + try { + data = event.data.json(); + } catch (e) { + data.body = event.data.text(); + } + } + + const options = { + body: data.body, + icon: '/icon-512.png', + badge: '/icon-512.png', + vibrate: [200, 100, 200], + data: { + dateOfArrival: Date.now(), + primaryKey: '1' + }, + actions: [ + { action: 'explore', title: 'Apri Dashboard' }, + { action: 'close', title: 'Chiudi' } + ] + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + if (event.action === 'explore') { + event.waitUntil( + clients.openWindow('/') + ); + } +}); diff --git a/systemd/fleet-control.service b/systemd/fleet-control.service new file mode 100644 index 0000000..d00d798 --- /dev/null +++ b/systemd/fleet-control.service @@ -0,0 +1,19 @@ +[Unit] +Description=Fleet Control Console - Central Hub +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/web-control +ExecStart=/usr/local/bin/gunicorn -k "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" -w 1 --graceful-timeout 2 --bind 0.0.0.0:9000 app:app +TimeoutStopSec=3 +Restart=always +RestartSec=5 + +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=fleet-console + +[Install] +WantedBy=multi-user.target diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..9ef8e54 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,1230 @@ + + + + + + + Fleet Control Console + + + + + + + + + + + +
+
+
FLEET CONTROL CONSOLE
+
+ + +
+
+
+
+ +
+ +
+

+ ๐Ÿ“Š DAILY STATISTICS +

+ +
+ +
+
+

๐ŸŽฏ TOP TALKGROUPS

+
Loading...
+
+ +
+

๐Ÿ—ฃ๏ธ TOP CALLSIGNS

+
Loading...
+
+ +
+
+
--s
+
โฑ๏ธ AVERAGE DURATION
+
+
+
+
--
+
๐Ÿ“ก TRANSITS TODAY
+
+
+
+ +
+

+ ๐Ÿ“ก Lastheard +

+
+ + + + + + + + + + + + + + +
TimeRepeaterModeSlotCallsignTarget / TGDurationBER
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-push.py b/test-push.py new file mode 100644 index 0000000..f44a7c5 --- /dev/null +++ b/test-push.py @@ -0,0 +1,11 @@ +import sqlite3 +import json +from app import broadcast_push_notification, init_db + +# Proviamo a inviare una notifica a tutti gli iscritti nel DB +print("๐Ÿš€ Starting notification test...") +broadcast_push_notification( + "โš ๏ธ ALLERTA FLOTTA", + "Test Push Notification: System Online!" +) +print("โœ… Command sent.")