1390 lines
55 KiB
Python
1390 lines
55 KiB
Python
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
|
|
import re
|
|
import queue
|
|
import hashlib
|
|
import secrets
|
|
from contextlib import closing
|
|
from pywebpush import webpush, WebPushException
|
|
from logging.handlers import RotatingFileHandler
|
|
from flask_socketio import SocketIO, emit
|
|
|
|
# --- DYNAMIC BASE PATH ---
|
|
# Calcola automaticamente la directory esatta in cui si trova questo file (app.py)
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# --- PATHS ---
|
|
DB_PATH = os.path.join(BASE_DIR, 'monitor.db')
|
|
CACHE_FILE = os.path.join(BASE_DIR, 'telemetry_cache.json')
|
|
CONFIG_PATH = os.path.join(BASE_DIR, 'config.json')
|
|
DMR_IDS_PATH = os.path.join(BASE_DIR, 'dmrid.dat')
|
|
NXDN_IDS_PATH = os.path.join(BASE_DIR, 'nxdn.csv')
|
|
CLIENTS_PATH = os.path.join(BASE_DIR, 'clients.json')
|
|
LOG_FILE = os.path.join(BASE_DIR, 'fleet_console.log')
|
|
|
|
# --- LOGGING CONFIGURATION ---
|
|
logging.basicConfig(
|
|
handlers=[
|
|
RotatingFileHandler(LOG_FILE, 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)
|
|
|
|
# --- CONFIG (caricata subito: serve a secret_key, CORS, utente di default, ecc.) ---
|
|
config_lock = threading.Lock()
|
|
|
|
def read_config():
|
|
with open(CONFIG_PATH, 'r') as f:
|
|
return json.load(f)
|
|
|
|
def write_config(cfg):
|
|
"""Scrittura atomica di config.json (file temporaneo + replace), mantenendo i permessi."""
|
|
tmp = CONFIG_PATH + '.tmp'
|
|
with config_lock:
|
|
with open(tmp, 'w') as f:
|
|
json.dump(cfg, f, indent=4)
|
|
try:
|
|
os.chmod(tmp, os.stat(CONFIG_PATH).st_mode & 0o777)
|
|
except OSError:
|
|
pass
|
|
os.replace(tmp, CONFIG_PATH)
|
|
|
|
config = read_config()
|
|
web_cfg = config.get('web', {}) # opzionale: secret_key, cookie_secure, session_hours, cors_origins, trust_proxy
|
|
|
|
def get_secret_key():
|
|
"""Chiave di sessione: da FLEET_SECRET_KEY, da config.json oppure generata e salvata."""
|
|
key = os.environ.get('FLEET_SECRET_KEY') or config.get('secret_key')
|
|
if not key or key == 'ari_fvg_secret_ultra_secure':
|
|
key = secrets.token_hex(32)
|
|
config['secret_key'] = key
|
|
try:
|
|
write_config(config)
|
|
logger.warning("🔑 Nuova secret_key generata e salvata in config.json (le sessioni esistenti sono state invalidate).")
|
|
except Exception as e:
|
|
logger.error(f"Impossibile salvare la secret_key in config.json ({e}): sarà valida solo fino al riavvio.")
|
|
return key
|
|
|
|
def init_db():
|
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
|
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 sqlite3.OperationalError: pass
|
|
|
|
try:
|
|
c.execute("ALTER TABLE radio_logs ADD COLUMN source_ext TEXT DEFAULT ''")
|
|
except sqlite3.OperationalError: 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("CREATE INDEX IF NOT EXISTS idx_radio_logs_ts ON radio_logs(timestamp)")
|
|
c.execute("CREATE INDEX IF NOT EXISTS idx_radio_logs_client_ts ON radio_logs(client_id, timestamp)")
|
|
c.execute("CREATE INDEX IF NOT EXISTS idx_audit_logs_ts ON audit_logs(timestamp)")
|
|
|
|
c.execute("SELECT COUNT(*) FROM users")
|
|
if c.fetchone()[0] == 0:
|
|
adm_cfg = config.get("web_admin", {})
|
|
def_user = adm_cfg.get("default_user", "admin")
|
|
def_pass = adm_cfg.get("default_pass", "")
|
|
generated = False
|
|
if not def_pass or def_pass == "admin123":
|
|
# niente password di default note: ne generiamo una casuale
|
|
def_pass = secrets.token_urlsafe(9)
|
|
generated = True
|
|
|
|
c.execute("INSERT INTO users (username, password_hash, role, allowed_nodes) VALUES (?,?,?,?)",
|
|
(def_user, generate_password_hash(def_pass), 'admin', 'all'))
|
|
if generated:
|
|
logger.warning(f">>> DEFAULT USER CREATED - User: {def_user} | Temporary password: {def_pass} | CHANGE IT NOW <<<")
|
|
else:
|
|
logger.info(f">>> DEFAULT USER CREATED - User: {def_user} (password from config.json) <<<")
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
init_db()
|
|
|
|
# --- ID DATABASE LOADING ---
|
|
user_db = {}
|
|
nxdn_db = {}
|
|
|
|
def load_ids():
|
|
global user_db, nxdn_db
|
|
new_dmr, new_nxdn = {}, {}
|
|
|
|
if os.path.exists(DMR_IDS_PATH):
|
|
with open(DMR_IDS_PATH, 'r', encoding='utf-8', errors='ignore') as f:
|
|
for l in f:
|
|
l = l.replace('"', '') # Rimuove eventuali virgolette
|
|
if ',' in l: p = l.strip().split(',')
|
|
elif '\t' in l: p = l.strip().split('\t')
|
|
elif ';' in l: p = l.strip().split(';')
|
|
else: p = l.strip().split() # Cerca gli spazi normali
|
|
|
|
if len(p) >= 2 and p[0].strip().isdigit():
|
|
new_dmr[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():
|
|
new_nxdn[p[0].strip()] = p[1].strip()
|
|
|
|
# Un file vuoto/corrotto non deve cancellare un database valido già in memoria
|
|
if new_dmr: user_db = new_dmr
|
|
if new_nxdn: nxdn_db = new_nxdn
|
|
|
|
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):
|
|
try:
|
|
tmp = CACHE_FILE + '.tmp'
|
|
with open(tmp, 'w') as f: json.dump(data, f)
|
|
os.replace(tmp, CACHE_FILE)
|
|
except Exception as e:
|
|
logger.error(f"Errore salvataggio cache telemetria: {e}")
|
|
notify_update()
|
|
|
|
def save_to_sqlite(client_id, data, protocol="DMR"):
|
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
|
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('src_id', '---')), str(data.get('dst_id', '---')), data.get('slot', 0), round(data.get('duration', 0), 1), round(data.get('ber', 0), 2), str(data.get('src_ext', ''))))
|
|
conn.commit()
|
|
conn.close()
|
|
notify_update('log_aggiornato')
|
|
|
|
app = Flask(__name__)
|
|
socketio = SocketIO(app, cors_allowed_origins=web_cfg.get('cors_origins', '*'))
|
|
app.secret_key = get_secret_key()
|
|
app.config.update(
|
|
SESSION_COOKIE_HTTPONLY=True,
|
|
SESSION_COOKIE_SAMESITE='Lax',
|
|
SESSION_COOKIE_SECURE=bool(web_cfg.get('cookie_secure', False)), # metti true se servi la dashboard solo in HTTPS
|
|
PERMANENT_SESSION_LIFETIME=int(web_cfg.get('session_hours', 12)) * 3600,
|
|
)
|
|
|
|
# --- NOTIFICHE WEBSOCKET "COALESCED" ---
|
|
# Molti messaggi MQTT ravvicinati generano UN solo evento ogni NOTIFY_DELAY secondi
|
|
# (altrimenti ogni browser farebbe 3 richieste HTTP per ogni messaggio).
|
|
NOTIFY_DELAY = 0.3
|
|
_notify_lock = threading.Lock()
|
|
_notify_pending = set()
|
|
|
|
def notify_update(event='dati_aggiornati'):
|
|
with _notify_lock:
|
|
if event in _notify_pending:
|
|
return
|
|
_notify_pending.add(event)
|
|
|
|
def _fire():
|
|
with _notify_lock:
|
|
_notify_pending.discard(event)
|
|
try:
|
|
socketio.emit(event)
|
|
except Exception as e:
|
|
logger.error(f"Errore emit WebSocket '{event}': {e}")
|
|
|
|
timer = threading.Timer(NOTIFY_DELAY, _fire)
|
|
timer.daemon = True
|
|
timer.start()
|
|
client_states = {}
|
|
device_configs = {}
|
|
client_telemetry = {}
|
|
last_notified_errors = {}
|
|
device_health = {}
|
|
last_seen_reflector = {}
|
|
network_mapping = {}
|
|
node_info = {}
|
|
node_general = {}
|
|
|
|
if os.path.exists(CACHE_FILE):
|
|
try:
|
|
with open(CACHE_FILE, 'r') as f: client_telemetry = json.load(f)
|
|
except: client_telemetry = {}
|
|
|
|
active_calls = {}
|
|
|
|
# --- DIAGNOSTICA: ultimo messaggio MQTT ricevuto, per nodo e per tipo ---
|
|
# mmdvm -> mmdvm/<id>/json (eventi radio: TX, cambio modo...)
|
|
# gateway -> dmr/nxdn/ysf/p25/dstar-gateway/<id>/json
|
|
# health -> devices/<id>/services (heartbeat dell'agent sul nodo)
|
|
START_TIME = time.time()
|
|
last_seen = {}
|
|
last_seen_lock = threading.Lock()
|
|
|
|
def touch_seen(cid, kind):
|
|
with last_seen_lock:
|
|
last_seen.setdefault(cid, {})[kind] = time.time()
|
|
|
|
def seen_ages():
|
|
"""Età (in secondi) dell'ultimo messaggio per nodo/tipo, calcolata sul server (niente problemi di orologio)."""
|
|
now = time.time()
|
|
with last_seen_lock:
|
|
return {cid: {k: round(now - ts, 1) for k, ts in kinds.items()} for cid, kinds in last_seen.items()}
|
|
|
|
def seed_last_seen():
|
|
"""Dopo un riavvio del server si riparte dall'ultimo evento radio registrato nel DB (dato reale)."""
|
|
try:
|
|
with closing(sqlite3.connect(DB_PATH, timeout=10)) as conn:
|
|
rows = conn.execute("""SELECT LOWER(client_id), MAX(timestamp) FROM radio_logs
|
|
WHERE source_id NOT LIKE '🌐%' GROUP BY LOWER(client_id)""").fetchall()
|
|
for cid, ts in rows:
|
|
try:
|
|
parsed = time.mktime(time.strptime(ts, '%Y-%m-%d %H:%M:%S'))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
with last_seen_lock:
|
|
last_seen.setdefault(cid, {}).setdefault('mmdvm', parsed)
|
|
except Exception as e:
|
|
logger.error(f"seed_last_seen: {e}")
|
|
|
|
seed_last_seen()
|
|
|
|
# --- MQTT CALLBACKS ---
|
|
mqtt_connected_status = False
|
|
|
|
def on_connect(client, userdata, flags, reason_code, properties=None):
|
|
global mqtt_connected_status
|
|
if reason_code == 0:
|
|
mqtt_connected_status = True
|
|
logger.info("✅ Successfully connected to MQTT Broker! Subscribing to topics...")
|
|
# Invia lo stato Online ai client web
|
|
socketio.emit('mqtt_status', {'connected': True})
|
|
|
|
# --- LETTURA DINAMICA DEI TOPIC ---
|
|
default_topics = [
|
|
"servizi/+/stat", "dmr-gateway/+/json", "devices/+/services",
|
|
"nxdn-gateway/+/json", "ysf-gateway/+/json", "p25-gateway/+/json",
|
|
"dstar-gateway/+/json", "mmdvm/+/json", "data/#"
|
|
]
|
|
|
|
# Cerca la lista "topics" nel config.json, se non la trova usa quella di default
|
|
topics_list = config.get('mqtt', {}).get('topics', default_topics)
|
|
|
|
# Converte la lista di stringhe nel formato richiesto da paho-mqtt: [(topic, qos), (topic, qos)...]
|
|
subscribe_list = [(topic, 0) for topic in topics_list]
|
|
|
|
client.subscribe(subscribe_list)
|
|
logger.info(f"Subscribed to {len(subscribe_list)} MQTT topics.")
|
|
else:
|
|
mqtt_connected_status = False
|
|
socketio.emit('mqtt_status', {'connected': False})
|
|
logger.error(f"❌ MQTT Connection Error. Reason code: {reason_code}")
|
|
|
|
def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None):
|
|
global mqtt_connected_status
|
|
mqtt_connected_status = False
|
|
# Invia lo stato Offline ai client web
|
|
socketio.emit('mqtt_status', {'connected': False})
|
|
logger.warning(f"⚠️ MQTT Disconnection detected! Reason code: {reason_code}. Attempting automatic reconnection...")
|
|
|
|
# Quando un nuovo utente apre la pagina web, inviagli subito lo stato attuale
|
|
@socketio.on('connect')
|
|
def handle_connect():
|
|
emit('mqtt_status', {'connected': mqtt_connected_status})
|
|
|
|
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()
|
|
|
|
# --- MAGIA DELLA NORMALIZZAZIONE ---
|
|
# Prendiamo i blocchi del topic, li facciamo minuscoli e togliamo i trattini
|
|
# Così "DMRGateway", "dmr-gateway" e "dmrgateway" diventano tutti identici per noi
|
|
p0 = parts[0].lower().replace('-', '')
|
|
p2 = parts[2].lower().replace('-', '') if len(parts) > 2 else ""
|
|
|
|
# --- DIAGNOSTICA: registra l'arrivo dell'ultimo messaggio per tipo ---
|
|
if p0 == 'mmdvm':
|
|
touch_seen(cid, 'mmdvm')
|
|
elif p0 in ('dmrgateway', 'nxdngateway', 'ysfgateway', 'p25gateway', 'dstargateway'):
|
|
touch_seen(cid, 'gateway')
|
|
elif p0 == 'devices' and p2 == 'services':
|
|
touch_seen(cid, 'health')
|
|
|
|
# --- CAPTURE FULL CONFIGURATIONS ---
|
|
if p0 == 'data' and len(parts) >= 4 and parts[3].lower() == '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 p0 == 'servizi':
|
|
client_states[cid] = payload
|
|
notify_update() # <--- 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.", cid=cid)
|
|
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.", cid=cid)
|
|
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 p0 == 'devices' and len(parts) >= 3 and p2 == 'services':
|
|
try:
|
|
data = json.loads(payload)
|
|
new_health = {
|
|
"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"})
|
|
}
|
|
health_changed = device_health.get(cid) != new_health
|
|
device_health[cid] = new_health
|
|
if health_changed:
|
|
notify_update() # <--- 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, cid=cid)
|
|
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.", cid=cid)
|
|
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 p0 == 'data' and p2 == '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
|
|
notify_update() # <--- WEBSOCKET
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error parsing DMRGateway for {cid}: {e}")
|
|
|
|
# --- MMDVMHOST INFO MANAGEMENT (FREQUENZE & LOCATION) ---
|
|
elif len(parts) >= 4 and p0 == 'data' and p2 == 'mmdvm' and parts[3].lower() == 'info':
|
|
try:
|
|
cid = parts[1].lower()
|
|
data = json.loads(payload)
|
|
|
|
# Estrazione dati
|
|
tx = data.get("TXFrequency", "0")
|
|
rx = data.get("RXFrequency", "0")
|
|
lat = data.get("Latitude", "0.0")
|
|
lon = data.get("Longitude", "0.0")
|
|
loc = data.get("Location", "Sconosciuta")
|
|
|
|
def format_freq(f):
|
|
if str(f).isdigit() and int(f) > 0:
|
|
return f"{int(f)/1000000:.3f} MHz"
|
|
return str(f)
|
|
|
|
node_info[cid] = {
|
|
"tx": format_freq(tx),
|
|
"rx": format_freq(rx),
|
|
"lat": lat,
|
|
"lon": lon,
|
|
"loc": loc
|
|
}
|
|
notify_update()
|
|
except Exception as e:
|
|
logger.error(f"Error parsing MMDVMHost info for {cid}: {e}")
|
|
|
|
# --- MMDVMHOST GENERAL MANAGEMENT (CALLSIGN & ID & DUPLEX) ---
|
|
elif len(parts) >= 4 and p0 == 'data' and p2 == 'mmdvm' and parts[3].lower() == 'general':
|
|
try:
|
|
cid = parts[1].lower()
|
|
data = json.loads(payload)
|
|
callsign = data.get("Callsign", "")
|
|
radio_id = data.get("Id", "")
|
|
duplex = data.get("Duplex", "1") # 1 = Repeater, 0 = Simplex
|
|
|
|
if callsign:
|
|
node_general[cid] = {"callsign": callsign, "radio_id": radio_id, "duplex": str(duplex)}
|
|
notify_update()
|
|
except Exception as e:
|
|
logger.error(f"Error parsing MMDVMHost general for {cid}: {e}")
|
|
|
|
# --- OTHER GATEWAYS MANAGEMENT ---
|
|
elif p0 in ['dmrgateway', 'nxdngateway', 'ysfgateway', 'p25gateway', 'dstargateway']:
|
|
data = json.loads(payload)
|
|
proto = "DMR"
|
|
if "nxdn" in p0: proto = "NXDN"
|
|
elif "ysf" in p0: proto = "YSF"
|
|
elif "p25" in p0: proto = "P25"
|
|
elif "dstar" in p0: 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, {'src_id': "🌐 " + m, 'dst_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('src_id'))
|
|
dst = str(d.get('dst_id'))
|
|
active_calls[cid][sk] = {'src': src, 'dst': dst}
|
|
client_telemetry[cid]["alt"] = ""
|
|
client_telemetry[cid][sk] = f"🎙️ {src} ➔ TG {dst}"
|
|
notify_update() # <--- WEBSOCKET
|
|
elif act in ['end', 'lost']:
|
|
info = active_calls[cid].get(sk, {'src': '---', 'dst': '---'})
|
|
d['src_id'], d['dst_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('src_id', '---'), 'NXDN')
|
|
elif k == 'P25': src = get_call(p.get('src_id', '---'), 'DMR')
|
|
else: src = str(p.get('Callsign', p.get('src_callsign', p.get('src_info', p.get('src_id', '---')))))
|
|
|
|
t_list = [p.get('reflector'), p.get('dst_callsign'), p.get('dst_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('src_ext', ''))}
|
|
client_telemetry[cid].update({"ts1":"","ts2":"","alt": f"{ico} {name}: {src} ➔ {target}"})
|
|
notify_update() # <--- WEBSOCKET
|
|
|
|
elif act in ['end', 'lost']:
|
|
info = active_calls[cid].get(k, {'src': '---', 'dst': '---', 'ext': ''})
|
|
p.update({'src_id': info['src'], 'dst_id': info['dst'], 'src_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 ---
|
|
# Recuperiamo l'ID dal config. Se per caso manca, usiamo un default di emergenza.
|
|
mqtt_id = config['mqtt'].get('client_id', 'flask_backend_generic')
|
|
|
|
mqtt_backend = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2, mqtt_id)
|
|
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
|
|
try:
|
|
# Usiamo connect_async! Non blocca il server se l'IP è irraggiungibile
|
|
mqtt_backend.connect_async(config['mqtt']['broker'], config['mqtt']['port'])
|
|
mqtt_backend.loop_start()
|
|
logger.info(f"Avvio connessione MQTT asincrona verso {config['mqtt']['broker']}...")
|
|
except Exception as e:
|
|
logger.error(f"Errore critico nell'inizializzazione MQTT: {e}")
|
|
|
|
def mqtt_publish(topic, payload, qos=1):
|
|
"""Pubblica solo se il broker è raggiungibile (niente comandi 'vecchi' accodati e consegnati dopo)."""
|
|
if not mqtt_connected_status:
|
|
logger.warning(f"MQTT offline: pubblicazione su {topic} annullata")
|
|
return False
|
|
try:
|
|
info = mqtt_backend.publish(topic, payload, qos=qos)
|
|
if info.rc != mqtt_client.MQTT_ERR_SUCCESS:
|
|
logger.warning(f"MQTT publish fallita su {topic} (rc={info.rc})")
|
|
return False
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"MQTT publish error su {topic}: {e}")
|
|
return False
|
|
|
|
@app.after_request
|
|
def add_security_headers(resp):
|
|
resp.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
|
resp.headers.setdefault('X-Frame-Options', 'DENY')
|
|
resp.headers.setdefault('Referrer-Policy', 'same-origin')
|
|
if request.path.startswith('/api/'):
|
|
resp.headers['Cache-Control'] = 'no-store'
|
|
return resp
|
|
|
|
@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,
|
|
"info": node_info,
|
|
"general": node_general,
|
|
"last_seen": seen_ages(),
|
|
"uptime": int(time.time() - START_TIME)
|
|
})
|
|
|
|
@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, timeout=10)
|
|
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 timestamp >= date('now', 'localtime') AND timestamp < date('now', 'localtime', '+1 day'){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 timestamp >= date('now', 'localtime') AND timestamp < date('now', 'localtime', '+1 day'){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 timestamp >= date('now', 'localtime') AND timestamp < date('now', 'localtime', '+1 day'){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 timestamp >= date('now', 'localtime') AND timestamp < date('now', 'localtime', '+1 day'){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
|
|
})
|
|
|
|
# =====================================================================
|
|
# AUTENTICAZIONE / VALIDAZIONE
|
|
# =====================================================================
|
|
VALID_ROLES = ('admin', 'operator')
|
|
USERNAME_RE = re.compile(r'^[A-Za-z0-9_.@\-]{3,32}$')
|
|
CID_RE = re.compile(r'^[a-z0-9][a-z0-9_\-]{0,63}$')
|
|
SERVICE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.@\-]{0,63}$')
|
|
PROFILE_KEY_RE = re.compile(r'^[A-Za-z0-9_\-]{1,32}$')
|
|
TIME_RE = re.compile(r'^([01]\d|2[0-3]):[0-5]\d$')
|
|
MIN_PASSWORD_LEN = 8
|
|
|
|
# Comandi inviabili a un nodo (topic servizi/<id>/cmnd)
|
|
SIMPLE_COMMANDS = {'TG:ON', 'TG:OFF'}
|
|
PRIVILEGED_COMMANDS = {'REBOOT', 'RESET_HAT', 'UPDATE_AGENT'} # solo admin
|
|
SERVICE_ACTIONS = {'start', 'stop', 'restart'}
|
|
|
|
|
|
def db_connect(row=False):
|
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
|
if row:
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def json_body():
|
|
d = request.get_json(silent=True)
|
|
return d if isinstance(d, dict) else {}
|
|
|
|
|
|
def err(msg, code=400):
|
|
return jsonify({"success": False, "error": msg}), code
|
|
|
|
|
|
def pw_fingerprint(password_hash):
|
|
"""Impronta dell'hash: cambiando password le vecchie sessioni diventano invalide."""
|
|
return hashlib.sha256(password_hash.encode()).hexdigest()[:16]
|
|
|
|
|
|
def get_auth():
|
|
"""Utente della sessione, riletto dal DB a ogni richiesta (ruolo/nodi sempre aggiornati)."""
|
|
username = session.get('user')
|
|
if not session.get('logged_in') or not username:
|
|
return None
|
|
with closing(db_connect(row=True)) as conn:
|
|
row = conn.execute("SELECT username, password_hash, role, allowed_nodes FROM users WHERE username = ?",
|
|
(username,)).fetchone()
|
|
if not row or session.get('pv') != pw_fingerprint(row['password_hash']):
|
|
session.clear() # utente cancellato o password cambiata
|
|
return None
|
|
return {"username": row['username'], "role": row['role'], "allowed_nodes": row['allowed_nodes'] or ''}
|
|
|
|
|
|
def node_allowed(role, allowed_nodes, cid):
|
|
if role == 'admin':
|
|
return True
|
|
allowed = (allowed_nodes or '').strip().lower()
|
|
if allowed == 'all':
|
|
return True
|
|
return cid in [x.strip().lower() for x in allowed.split(',') if x.strip()]
|
|
|
|
|
|
def known_profile_keys(cid=None):
|
|
"""Chiavi di profilo valide (A/B + quelle annunciate dai nodi)."""
|
|
keys = {'A', 'B'}
|
|
healths = [device_health.get(cid)] if cid else list(device_health.values())
|
|
for h in healths:
|
|
profiles = (h or {}).get('profiles') or {}
|
|
if isinstance(profiles, dict):
|
|
keys.update(str(k) for k in profiles.keys() if PROFILE_KEY_RE.match(str(k)))
|
|
return keys - PRIVILEGED_COMMANDS - SIMPLE_COMMANDS
|
|
|
|
|
|
def audit(username, client_id, command):
|
|
try:
|
|
with closing(db_connect()) as conn:
|
|
conn.execute("INSERT INTO audit_logs (timestamp, username, client_id, command) VALUES (datetime('now','localtime'), ?, ?, ?)",
|
|
(username, client_id, command))
|
|
conn.commit()
|
|
except Exception as e:
|
|
logger.error(f"Audit log error: {e}")
|
|
|
|
|
|
def count_admins(cursor, exclude_id=None):
|
|
if exclude_id is None:
|
|
cursor.execute("SELECT COUNT(*) FROM users WHERE role = 'admin'")
|
|
else:
|
|
cursor.execute("SELECT COUNT(*) FROM users WHERE role = 'admin' AND id != ?", (exclude_id,))
|
|
return cursor.fetchone()[0]
|
|
|
|
|
|
def normalize_nodes(value):
|
|
value = (value or '').strip().lower()
|
|
if not value or value == 'all':
|
|
return 'all'
|
|
return ','.join(x.strip() for x in value.split(',') if x.strip())
|
|
|
|
|
|
# --- Limitazione tentativi di login (per IP, in memoria) ---
|
|
LOGIN_MAX_FAILS = 5
|
|
LOGIN_WINDOW_SEC = 300
|
|
login_attempts = {}
|
|
login_lock = threading.Lock()
|
|
|
|
|
|
def client_ip():
|
|
# X-Forwarded-For si fida solo se esplicitamente abilitato ("web": {"trust_proxy": true})
|
|
if config.get('web', {}).get('trust_proxy'):
|
|
fwd = request.headers.get('X-Forwarded-For', '')
|
|
if fwd:
|
|
return fwd.split(',')[0].strip()
|
|
return request.remote_addr or 'unknown'
|
|
|
|
|
|
def login_blocked(ip):
|
|
now = time.time()
|
|
with login_lock:
|
|
fails = [x for x in login_attempts.get(ip, []) if now - x < LOGIN_WINDOW_SEC]
|
|
if fails:
|
|
login_attempts[ip] = fails
|
|
else:
|
|
login_attempts.pop(ip, None)
|
|
return len(fails) >= LOGIN_MAX_FAILS
|
|
|
|
|
|
def login_register_fail(ip):
|
|
with login_lock:
|
|
login_attempts.setdefault(ip, []).append(time.time())
|
|
|
|
|
|
def login_reset(ip):
|
|
with login_lock:
|
|
login_attempts.pop(ip, None)
|
|
|
|
|
|
# =====================================================================
|
|
# ROUTE: SESSIONE
|
|
# =====================================================================
|
|
@app.route('/api/login', methods=['POST'])
|
|
def login():
|
|
d = json_body()
|
|
username, password = d.get('user'), d.get('pass')
|
|
if not isinstance(username, str) or not isinstance(password, str) or not username or not password:
|
|
return err("Missing credentials", 400)
|
|
|
|
ip = client_ip()
|
|
if login_blocked(ip):
|
|
logger.warning(f"Login bloccato (troppi tentativi) da {ip}")
|
|
return err("Too many failed attempts. Try again in a few minutes.", 429)
|
|
|
|
with closing(db_connect(row=True)) as conn:
|
|
user = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
|
|
|
|
if user and check_password_hash(user['password_hash'], password):
|
|
login_reset(ip)
|
|
session.clear()
|
|
session.permanent = True
|
|
session['logged_in'] = True
|
|
session['user'] = user['username']
|
|
session['pv'] = pw_fingerprint(user['password_hash'])
|
|
logger.info(f"Login OK: {user['username']} da {ip}")
|
|
return jsonify({"success": True, "role": user['role'], "allowed_nodes": user['allowed_nodes']})
|
|
|
|
login_register_fail(ip)
|
|
logger.warning(f"Login fallito per {username[:32]!r} da {ip}")
|
|
return jsonify({"success": False}), 401
|
|
|
|
|
|
@app.route('/api/logout', methods=['POST'])
|
|
def logout():
|
|
session.clear()
|
|
return jsonify({"success": True})
|
|
|
|
|
|
@app.route('/api/me')
|
|
def me():
|
|
auth = get_auth()
|
|
if not auth:
|
|
return jsonify({"logged_in": False})
|
|
return jsonify({"logged_in": True, "user": auth['username'], "role": auth['role'],
|
|
"allowed_nodes": auth['allowed_nodes']})
|
|
|
|
|
|
# =====================================================================
|
|
# ROUTE: COMANDI AI NODI
|
|
# =====================================================================
|
|
@app.route('/api/command', methods=['POST'])
|
|
def cmd():
|
|
auth = get_auth()
|
|
if not auth:
|
|
return err("Not authenticated", 403)
|
|
|
|
d = json_body()
|
|
cid = str(d.get('clientId', '')).strip().lower()
|
|
cmd_type = str(d.get('type', '')).strip()
|
|
|
|
if not CID_RE.match(cid):
|
|
return err("Invalid node id")
|
|
if not node_allowed(auth['role'], auth['allowed_nodes'], cid):
|
|
return err("You do not have permission for this node.", 403)
|
|
|
|
if cmd_type in PRIVILEGED_COMMANDS:
|
|
if auth['role'] != 'admin':
|
|
return err("Only Admins can run this command.", 403)
|
|
elif cmd_type in SIMPLE_COMMANDS:
|
|
pass
|
|
elif cmd_type in known_profile_keys(cid):
|
|
pass
|
|
else:
|
|
logger.warning(f"Comando non ammesso da {auth['username']}: {cmd_type[:40]!r} -> {cid}")
|
|
return err("Unknown command")
|
|
|
|
if not mqtt_publish(f"servizi/{cid}/cmnd", cmd_type):
|
|
return err("MQTT broker offline - command not sent", 503)
|
|
|
|
audit(auth['username'], cid, cmd_type)
|
|
client_telemetry[cid] = {"ts1": "🔄 Sent...", "ts2": "🔄 Sent...", "alt": ""}
|
|
return jsonify({"success": True})
|
|
|
|
|
|
@app.route('/api/global_command', methods=['POST'])
|
|
def global_cmd():
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Admin action only!", 403)
|
|
|
|
cmd_type = str(json_body().get('type', '')).strip()
|
|
if cmd_type not in known_profile_keys():
|
|
return err("Unknown profile")
|
|
|
|
clients_list = []
|
|
if os.path.exists(CLIENTS_PATH):
|
|
with open(CLIENTS_PATH, 'r') as f:
|
|
clients_list = json.load(f)
|
|
|
|
sent, failed = 0, 0
|
|
for client in clients_list:
|
|
cid = str(client.get('id', '')).lower()
|
|
if not CID_RE.match(cid):
|
|
continue
|
|
if mqtt_publish(f"servizi/{cid}/cmnd", cmd_type):
|
|
audit(auth['username'], cid, f"GLOBAL_OVERRIDE_{cmd_type}")
|
|
sent += 1
|
|
else:
|
|
failed += 1
|
|
if failed and not sent:
|
|
return err("MQTT broker offline - command not sent", 503)
|
|
return jsonify({"success": True, "sent": sent, "failed": failed})
|
|
|
|
|
|
@app.route('/api/service_control', methods=['POST'])
|
|
def service_control():
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Unauthorized", 403)
|
|
|
|
d = json_body()
|
|
cid = str(d.get('clientId', '')).strip().lower()
|
|
action = str(d.get('action', '')).strip().lower()
|
|
service = str(d.get('service', '')).strip()
|
|
if not CID_RE.match(cid):
|
|
return err("Invalid node id")
|
|
if action not in SERVICE_ACTIONS:
|
|
return err("Invalid action")
|
|
if not SERVICE_RE.match(service):
|
|
return err("Invalid service name")
|
|
|
|
if not mqtt_publish(f"devices/{cid}/control", f"{action}:{service}"):
|
|
return err("MQTT broker offline - command not sent", 503)
|
|
audit(auth['username'], cid, f"SVC_{action.upper()}_{service}")
|
|
return jsonify({"success": True})
|
|
|
|
|
|
@app.route('/api/update_nodes', methods=['POST'])
|
|
def update_nodes():
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Unauthorized", 403)
|
|
if not mqtt_publish("devices/control/request", "update"):
|
|
return err("MQTT broker offline - command not sent", 503)
|
|
logger.info(f"📢 REQ CONFIG inviata a tutta la flotta da {auth['username']}")
|
|
audit(auth['username'], '*', 'REQ_CONFIG_ALL')
|
|
return jsonify({"success": True})
|
|
|
|
|
|
# =====================================================================
|
|
# ROUTE: UTENTI
|
|
# =====================================================================
|
|
@app.route('/api/users', methods=['GET'])
|
|
def get_users():
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Unauthorized", 403)
|
|
with closing(db_connect(row=True)) as conn:
|
|
users = [dict(r) for r in conn.execute("SELECT id, username, role, allowed_nodes FROM users")]
|
|
return jsonify(users)
|
|
|
|
|
|
@app.route('/api/users', methods=['POST'])
|
|
def add_user():
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Unauthorized", 403)
|
|
|
|
d = json_body()
|
|
username = str(d.get('username', '')).strip()
|
|
password = d.get('password')
|
|
role = d.get('role', 'operator')
|
|
allowed = normalize_nodes(d.get('allowed_nodes', 'all'))
|
|
|
|
if not username or not password:
|
|
return err("Missing data")
|
|
if not USERNAME_RE.match(username):
|
|
return err("Invalid username (3-32 chars: letters, digits, . _ @ -)")
|
|
if not isinstance(password, str) or len(password) < MIN_PASSWORD_LEN:
|
|
return err(f"Password must be at least {MIN_PASSWORD_LEN} characters")
|
|
if role not in VALID_ROLES:
|
|
return err("Invalid role")
|
|
|
|
try:
|
|
with closing(db_connect()) as conn:
|
|
conn.execute("INSERT INTO users (username, password_hash, role, allowed_nodes) VALUES (?,?,?,?)",
|
|
(username, generate_password_hash(password), role, allowed))
|
|
conn.commit()
|
|
except sqlite3.IntegrityError:
|
|
return err("Username already exists")
|
|
audit(auth['username'], '-', f"USER_ADD_{username}_{role}")
|
|
return jsonify({"success": True})
|
|
|
|
|
|
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
|
|
def delete_user(user_id):
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Unauthorized", 403)
|
|
|
|
with closing(db_connect()) as conn:
|
|
c = conn.cursor()
|
|
c.execute("SELECT username, role FROM users WHERE id = ?", (user_id,))
|
|
u = c.fetchone()
|
|
if not u:
|
|
return err("User not found", 404)
|
|
if u[0] == auth['username']:
|
|
return err("You cannot delete yourself!")
|
|
if u[1] == 'admin' and count_admins(c, exclude_id=user_id) == 0:
|
|
return err("You cannot delete the last admin!")
|
|
c.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
|
c.execute("DELETE FROM push_subscriptions WHERE username = ?", (u[0],))
|
|
conn.commit()
|
|
audit(auth['username'], '-', f"USER_DEL_{u[0]}")
|
|
return jsonify({"success": True})
|
|
|
|
|
|
@app.route('/api/users/<int:user_id>', methods=['PUT'])
|
|
def update_user(user_id):
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Unauthorized", 403)
|
|
|
|
d = json_body()
|
|
password = d.get('password')
|
|
if password is not None and not isinstance(password, str):
|
|
return err("Invalid password")
|
|
|
|
with closing(db_connect()) as conn:
|
|
c = conn.cursor()
|
|
c.execute("SELECT username, role, allowed_nodes FROM users WHERE id = ?", (user_id,))
|
|
existing = c.fetchone()
|
|
if not existing:
|
|
return err("User not found", 404)
|
|
|
|
role = d.get('role', existing[1])
|
|
allowed = normalize_nodes(d.get('allowed_nodes', existing[2]))
|
|
if role not in VALID_ROLES:
|
|
return err("Invalid role")
|
|
if existing[1] == 'admin' and role != 'admin' and count_admins(c, exclude_id=user_id) == 0:
|
|
return err("You cannot demote the last admin!")
|
|
|
|
if password and password.strip() != "":
|
|
if len(password) < MIN_PASSWORD_LEN:
|
|
return err(f"Password must be at least {MIN_PASSWORD_LEN} characters")
|
|
c.execute("UPDATE users SET password_hash=?, role=?, allowed_nodes=? WHERE id=?",
|
|
(generate_password_hash(password), role, allowed, user_id))
|
|
else:
|
|
c.execute("UPDATE users SET role=?, allowed_nodes=? WHERE id=?", (role, allowed, user_id))
|
|
conn.commit()
|
|
audit(auth['username'], '-', f"USER_EDIT_{existing[0]}")
|
|
return jsonify({"success": True})
|
|
|
|
|
|
@app.route('/api/change_password', methods=['POST'])
|
|
def change_password():
|
|
auth = get_auth()
|
|
if not auth:
|
|
return err("Not authenticated", 403)
|
|
|
|
d = json_body()
|
|
new_pass = d.get('new_password')
|
|
user_to_change = d.get('username') or auth['username']
|
|
if auth['role'] != 'admin' and auth['username'] != user_to_change:
|
|
return err("Unauthorized", 403)
|
|
if not new_pass or not isinstance(new_pass, str):
|
|
return err("Password cannot be empty")
|
|
if len(new_pass) < MIN_PASSWORD_LEN:
|
|
return err(f"Password must be at least {MIN_PASSWORD_LEN} characters")
|
|
|
|
with closing(db_connect()) as conn:
|
|
cur = conn.execute("UPDATE users SET password_hash = ? WHERE username = ?",
|
|
(generate_password_hash(new_pass), user_to_change))
|
|
conn.commit()
|
|
if cur.rowcount == 0:
|
|
return err("User not found", 404)
|
|
audit(auth['username'], '-', f"PASSWORD_CHANGE_{user_to_change}")
|
|
return jsonify({"success": True})
|
|
|
|
|
|
# =====================================================================
|
|
# AGGIORNAMENTO DATABASE ID + PULIZIA DB
|
|
# =====================================================================
|
|
def download_file(url, dest):
|
|
"""Scarica in un file temporaneo e sostituisce l'originale solo a download riuscito."""
|
|
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
|
|
req = urllib.request.Request(url, headers=headers)
|
|
with urllib.request.urlopen(req, timeout=60) as response:
|
|
data = response.read()
|
|
if len(data) < 1024:
|
|
raise ValueError(f"file scaricato troppo piccolo ({len(data)} byte) da {url}")
|
|
tmp = dest + '.tmp'
|
|
with open(tmp, 'wb') as out_file:
|
|
out_file.write(data)
|
|
os.replace(tmp, dest)
|
|
|
|
|
|
def prune_old_data():
|
|
"""Retention: radio_logs (default 90 gg) e audit_logs (default 365 gg)."""
|
|
try:
|
|
cfg = read_config()
|
|
radio_days = int(cfg.get("retention_days", 90))
|
|
audit_days = int(cfg.get("audit_retention_days", 365))
|
|
with closing(db_connect()) as conn:
|
|
c = conn.cursor()
|
|
c.execute("DELETE FROM radio_logs WHERE timestamp < datetime('now', 'localtime', ?)", (f"-{radio_days} days",))
|
|
n1 = c.rowcount
|
|
c.execute("DELETE FROM audit_logs WHERE timestamp < datetime('now', 'localtime', ?)", (f"-{audit_days} days",))
|
|
n2 = c.rowcount
|
|
conn.commit()
|
|
if n1 or n2:
|
|
logger.info(f"🧹 Pulizia DB: {n1} ascolti e {n2} audit rimossi.")
|
|
except Exception as e:
|
|
logger.error(f"❌ Errore pulizia DB: {e}")
|
|
|
|
|
|
def auto_update_ids():
|
|
"""Controlla all'avvio e poi ogni notte all'orario configurato."""
|
|
|
|
def download_logic():
|
|
try:
|
|
urls = read_config().get("id_urls") or {}
|
|
url_dmr = urls.get("dmr") or "https://radioid.net/static/users.csv"
|
|
url_nxdn = urls.get("nxdn") or "https://radioid.net/static/nxdn.csv"
|
|
logger.info("📡 Inizio download database ID (DMR e NXDN)...")
|
|
download_file(url_dmr, DMR_IDS_PATH)
|
|
download_file(url_nxdn, NXDN_IDS_PATH)
|
|
load_ids()
|
|
logger.info(f"✅ Aggiornamento completato ({len(user_db)} ID DMR, {len(nxdn_db)} ID NXDN).")
|
|
except Exception as e:
|
|
logger.error(f"❌ Errore durante il download: {e}")
|
|
|
|
if not os.path.exists(DMR_IDS_PATH) or not os.path.exists(NXDN_IDS_PATH):
|
|
logger.info("🔍 File ID mancanti. Avvio download immediato...")
|
|
download_logic()
|
|
prune_old_data()
|
|
|
|
while True:
|
|
try:
|
|
now = time.strftime("%H:%M")
|
|
target_time = read_config().get("update_schedule", "03:00")
|
|
if now == target_time:
|
|
logger.info(f"⏰ Orario programmato ({target_time}) raggiunto. Aggiorno...")
|
|
download_logic()
|
|
prune_old_data()
|
|
time.sleep(65)
|
|
except Exception as e:
|
|
logger.error(f"⚠️ Errore nel thread update: {e}")
|
|
time.sleep(30)
|
|
|
|
|
|
# =====================================================================
|
|
# ROUTE: CONFIGURAZIONE
|
|
# =====================================================================
|
|
@app.route('/api/ui_config', methods=['GET'])
|
|
def get_ui_config():
|
|
try:
|
|
cfg = read_config()
|
|
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:
|
|
logger.error(f"ui_config error: {e}")
|
|
return jsonify({"error": "Configuration unavailable"}), 500
|
|
|
|
|
|
@app.route('/api/config', methods=['GET'])
|
|
def get_config_api():
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Unauthorized", 403)
|
|
cfg = read_config()
|
|
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():
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Unauthorized", 403)
|
|
|
|
d = json_body()
|
|
schedule = str(d.get("update_schedule", "03:00")).strip()
|
|
url_dmr = str(d.get("url_dmr", "")).strip()
|
|
url_nxdn = str(d.get("url_nxdn", "")).strip()
|
|
if not TIME_RE.match(schedule):
|
|
return err("Invalid time (use HH:MM)")
|
|
for u in (url_dmr, url_nxdn):
|
|
if not u.lower().startswith(('http://', 'https://')):
|
|
return err("URLs must start with http:// or https://")
|
|
|
|
cfg = read_config()
|
|
cfg["update_schedule"] = schedule
|
|
cfg.setdefault("id_urls", {})
|
|
cfg["id_urls"]["dmr"] = url_dmr
|
|
cfg["id_urls"]["nxdn"] = url_nxdn
|
|
write_config(cfg)
|
|
audit(auth['username'], '-', 'EDIT_SERVER_CONFIG')
|
|
return jsonify({"success": True})
|
|
|
|
|
|
@app.route('/api/config_file/<cid>/<service>', methods=['GET'])
|
|
def get_config_file(cid, service):
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Unauthorized", 403)
|
|
cid = cid.lower()
|
|
service = service.lower()
|
|
if not CID_RE.match(cid) or not SERVICE_RE.match(service):
|
|
return err("Invalid parameters")
|
|
config_data = device_configs.get(cid, {}).get(service)
|
|
if not config_data:
|
|
return err("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():
|
|
auth = get_auth()
|
|
if not auth or auth['role'] != 'admin':
|
|
return err("Unauthorized", 403)
|
|
|
|
d = json_body()
|
|
cid = str(d.get('clientId', '')).strip().lower()
|
|
service = str(d.get('service', '')).strip().lower()
|
|
new_config = d.get('config_data')
|
|
if not CID_RE.match(cid) or not SERVICE_RE.match(service):
|
|
return err("Invalid parameters")
|
|
if not isinstance(new_config, dict) or not isinstance(new_config.get('raw_text'), str):
|
|
return err("Invalid configuration payload")
|
|
if len(new_config['raw_text']) > 65536:
|
|
return err("Configuration too large (max 64 KB)")
|
|
|
|
if not mqtt_publish(f"devices/{cid}/config_set/{service}", json.dumps(new_config)):
|
|
return err("MQTT broker offline - configuration not sent", 503)
|
|
audit(auth['username'], cid, f"EDIT_CONFIG_{service.upper()}")
|
|
return jsonify({"success": True})
|
|
|
|
|
|
# =====================================================================
|
|
# PWA: MANIFEST / SERVICE WORKER / ICONA
|
|
# =====================================================================
|
|
@app.route('/manifest.json')
|
|
def serve_manifest():
|
|
return send_from_directory(BASE_DIR, 'manifest.json')
|
|
|
|
|
|
@app.route('/sw.js')
|
|
def serve_sw():
|
|
resp = send_from_directory(BASE_DIR, 'sw.js', mimetype='application/javascript')
|
|
resp.headers['Cache-Control'] = 'no-cache' # il browser controlla sempre se il SW è cambiato
|
|
resp.headers['Service-Worker-Allowed'] = '/'
|
|
return resp
|
|
|
|
|
|
@app.route('/icon-512.png')
|
|
def serve_icon():
|
|
return send_from_directory(BASE_DIR, 'icon-512.png')
|
|
|
|
|
|
# =====================================================================
|
|
# PUSH NOTIFICATIONS (invio in un thread dedicato: non blocca MQTT)
|
|
# =====================================================================
|
|
push_queue = queue.Queue(maxsize=200)
|
|
|
|
|
|
def broadcast_push_notification(title, body, cid=None):
|
|
if not config.get('webpush'):
|
|
return
|
|
try:
|
|
push_queue.put_nowait((title, body, cid))
|
|
except queue.Full:
|
|
logger.warning("Push queue piena: notifica scartata")
|
|
|
|
|
|
def _send_push(title, body, cid):
|
|
wp_config = config.get('webpush')
|
|
if not wp_config:
|
|
return
|
|
payload = json.dumps({"title": title, "body": body, "tag": cid or "fleet-alert"})
|
|
dead = []
|
|
|
|
with closing(db_connect()) as conn:
|
|
rows = conn.execute("""SELECT p.id, p.subscription, u.role, u.allowed_nodes
|
|
FROM push_subscriptions p
|
|
LEFT JOIN users u ON u.username = p.username""").fetchall()
|
|
for sub_id, sub_json, role, allowed in rows:
|
|
if role is None: # utente cancellato
|
|
dead.append(sub_id)
|
|
continue
|
|
if cid and not node_allowed(role, allowed, cid):
|
|
continue # l'operatore non gestisce questo nodo
|
|
try:
|
|
webpush(
|
|
subscription_info=json.loads(sub_json),
|
|
data=payload,
|
|
vapid_private_key=wp_config['vapid_private_key'],
|
|
vapid_claims={"sub": wp_config['vapid_claim_email']},
|
|
timeout=10
|
|
)
|
|
except WebPushException as ex:
|
|
status = getattr(getattr(ex, 'response', None), 'status_code', None)
|
|
if status in (404, 410):
|
|
dead.append(sub_id)
|
|
else:
|
|
logger.warning(f"Push error (status {status}): {ex}")
|
|
except Exception as e:
|
|
logger.error(f"Generic Push Error: {e}")
|
|
for sub_id in dead:
|
|
conn.execute("DELETE FROM push_subscriptions WHERE id = ?", (sub_id,))
|
|
if dead:
|
|
conn.commit()
|
|
|
|
|
|
def push_worker():
|
|
while True:
|
|
title, body, cid = push_queue.get()
|
|
try:
|
|
_send_push(title, body, cid)
|
|
except Exception as e:
|
|
logger.error(f"Push worker error: {e}")
|
|
|
|
|
|
@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():
|
|
auth = get_auth()
|
|
if not auth:
|
|
return err("Unauthorized", 403)
|
|
sub_data = json_body()
|
|
if not sub_data.get('endpoint'):
|
|
return err("Invalid subscription")
|
|
with closing(db_connect()) as conn:
|
|
# Se lo stesso browser si iscrive con un altro utente, la subscription passa al nuovo utente
|
|
conn.execute("""INSERT INTO push_subscriptions (username, subscription) VALUES (?, ?)
|
|
ON CONFLICT(subscription) DO UPDATE SET username = excluded.username""",
|
|
(auth['username'], json.dumps(sub_data)))
|
|
conn.commit()
|
|
return jsonify({"success": True})
|
|
|
|
|
|
threading.Thread(target=auto_update_ids, daemon=True).start()
|
|
threading.Thread(target=push_worker, daemon=True).start()
|
|
|
|
if __name__ == '__main__':
|
|
# Eseguire con UN SOLO processo: client MQTT, thread e cache sono in memoria.
|
|
socketio.run(app, host='0.0.0.0', port=9000)
|