Files
V-Fire-Monitor/vfire_monitor/core.py
T

495 lines
17 KiB
Python

import json
import logging
import os
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Tuple
from cryptography.fernet import Fernet, InvalidToken
from pyModbusTCP.client import ModbusClient
from werkzeug.security import check_password_hash, generate_password_hash
from zabbix_utils import ItemValue, Sender
DEFAULT_CONFIG_TEMPLATE = {
"zabbix_server": "172.16.33.6",
"hostname_zabbix": "NFS-320",
"web_user": "admin",
"nodes": [],
}
LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
MAX_DISCOVERY_ADDR = 600
MODBUS_BLOCK_SIZE = 120
DISCOVERY_IGNORED_VALUES = {0, 255, 65535}
POLL_INTERVAL_SECONDS = 2
RETRY_INTERVAL_SECONDS = 5
MAPA_STATUS_BASE = {
5120: {"label": "NORMAL / OK", "color": "#28a745"},
3088: {"label": "INCIDENTE", "color": "#dc3545"},
3344: {"label": "INCIDENTE ACK", "color": "#007bff"},
13312: {"label": "REMOVIDO", "color": "#fd7e14"},
0: {"label": "VAZIO", "color": "#6c757d"},
}
LOGGER = logging.getLogger("v-fire-monitor")
class User:
def __init__(self, user_id: str):
self.id = user_id
@property
def is_authenticated(self) -> bool:
return True
@property
def is_active(self) -> bool:
return True
@property
def is_anonymous(self) -> bool:
return False
def get_id(self) -> str:
return self.id
class JsonStore:
def __init__(self, path: Path):
self.path = path
def load(self, default):
if not self.path.exists():
return default
try:
with self.path.open("r", encoding="utf-8") as handle:
return json.load(handle)
except (json.JSONDecodeError, OSError) as exc:
LOGGER.warning("Falha ao carregar %s: %s", self.path.name, exc)
return default
def save(self, data) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temp_path = self.path.with_suffix(f"{self.path.suffix}.tmp")
with temp_path.open("w", encoding="utf-8") as handle:
json.dump(data, handle, indent=4, ensure_ascii=False)
temp_path.replace(self.path)
def build_default_config(default_password: str) -> dict:
config = dict(DEFAULT_CONFIG_TEMPLATE)
config["web_password_hash"] = generate_password_hash(default_password)
return config
def ensure_secret_key(secret_path: Path) -> str:
if secret_path.exists():
return secret_path.read_text(encoding="utf-8").strip()
secret = Fernet.generate_key().decode("ascii")
secret_path.write_text(secret, encoding="utf-8")
LOGGER.info("Chave de sessão criada em %s", secret_path.name)
return secret
def normalize_config(data: dict, default_password: str) -> dict:
config = build_default_config(default_password)
config.update(data or {})
if config.get("web_password") and not config.get("web_password_hash"):
config["web_password_hash"] = generate_password_hash(config["web_password"])
config.pop("web_password", None)
normalized_nodes = []
for raw_node in config.get("nodes", []):
try:
normalized_nodes.append(validate_node_payload(raw_node))
except ValueError as exc:
LOGGER.warning("Node ignorado durante normalizacao: %s", exc)
config["nodes"] = normalized_nodes
return config
def load_license_key(require_env: bool) -> bytes:
env_key = os.getenv("VFM_LICENSE_MASTER_KEY")
if env_key:
return env_key.encode("utf-8")
if require_env:
raise RuntimeError(
"VFM_LICENSE_MASTER_KEY e obrigatoria em producao. Defina a variavel de ambiente antes de iniciar o sistema."
)
LOGGER.warning(
"VFM_LICENSE_MASTER_KEY nao definido. Usando chave legada embutida; mova a chave para variavel de ambiente."
)
return LEGACY_LICENSE_KEY
def get_status_info(value: int) -> dict:
low_byte = value & 0xFF
return MAPA_STATUS_BASE.get(
value,
MAPA_STATUS_BASE.get(low_byte, {"label": f"ST {value}", "color": "#6c757d"}),
)
def get_hardware_id() -> str:
try:
if os.name == "nt":
import subprocess
output = subprocess.check_output(
[
"powershell",
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystemProduct).UUID.Trim()",
],
text=True,
)
return output.strip()
machine_id = Path("/etc/machine-id")
if machine_id.exists():
return machine_id.read_text(encoding="utf-8").strip()[:32]
except Exception as exc: # pragma: no cover
LOGGER.warning("Nao foi possivel obter hardware id: %s", exc)
return "ID-ERRO-VOLTEC-001"
def validate_node_payload(data: dict) -> dict:
if not isinstance(data, dict):
raise ValueError("Formato de node invalido.")
nome = str(data.get("nome", "")).strip()
ip = str(data.get("ip", "")).strip()
if not nome:
raise ValueError("Nome do node e obrigatorio.")
if not ip:
raise ValueError("IP do node e obrigatorio.")
try:
unit = int(data.get("unit"))
except (TypeError, ValueError):
raise ValueError(f"Unit invalido para node {nome}.") from None
if unit < 0 or unit > 255:
raise ValueError(f"Unit fora do intervalo para node {nome}.")
return {"nome": nome, "ip": ip, "unit": unit}
def contiguous_ranges(addresses: List[int]) -> List[Tuple[int, int]]:
if not addresses:
return []
sorted_addresses = sorted(set(addresses))
ranges: List[Tuple[int, int]] = []
start = sorted_addresses[0]
previous = start
for address in sorted_addresses[1:]:
if address == previous + 1 and (address - start + 1) <= MODBUS_BLOCK_SIZE:
previous = address
continue
ranges.append((start, previous))
start = previous = address
ranges.append((start, previous))
return ranges
def discover_node(node: dict, current_map: dict) -> dict:
client = ModbusClient(
host=node["ip"],
port=502,
unit_id=node["unit"],
auto_open=True,
timeout=1,
)
discovered = {}
try:
for start in range(1, MAX_DISCOVERY_ADDR + 1, MODBUS_BLOCK_SIZE):
count = min(MODBUS_BLOCK_SIZE, MAX_DISCOVERY_ADDR - start + 1)
registers = client.read_holding_registers(start, count)
if not registers:
continue
for offset, value in enumerate(registers):
if value in DISCOVERY_IGNORED_VALUES:
continue
address = start + offset
key = f"{node['ip']}_{node['unit']}_{address}"
existing = current_map.get(key, {})
if existing.get("label"):
label = existing["label"]
else:
device_type = "D" if address < 160 else ("M" if address < 350 else "P")
label = f"{node['nome']}-{device_type}{address:03}"
discovered[key] = {
"label": label,
"status": value,
"node_nome": node["nome"],
}
finally:
client.close()
return discovered
@dataclass
class LicenseStatus:
valid: bool
message: str
hardware_id: str
days_remaining: int
class LicenseService:
def __init__(self, license_path: Path, master_key: bytes):
self.license_path = license_path
self.fernet = Fernet(master_key)
def verify(self) -> LicenseStatus:
hardware_id = get_hardware_id()
if not self.license_path.exists():
return LicenseStatus(False, "Licenca ausente", hardware_id, 0)
try:
serial = self.license_path.read_text(encoding="utf-8").strip()
payload = json.loads(self.fernet.decrypt(serial.encode("utf-8")).decode("utf-8"))
licensed_hwid = str(payload["h"]).strip()
if licensed_hwid != hardware_id.strip():
return LicenseStatus(False, "HWID incompativel", hardware_id, 0)
expires_at = datetime.strptime(payload["e"], "%Y-%m-%d")
days_remaining = (expires_at.date() - datetime.now().date()).days
if days_remaining < 0:
return LicenseStatus(False, "Licenca expirada", hardware_id, 0)
return LicenseStatus(True, f"Licenciado para {payload['c']}", hardware_id, days_remaining)
except (InvalidToken, KeyError, ValueError, OSError, json.JSONDecodeError) as exc:
LOGGER.warning("Falha ao verificar licenca: %s", exc)
return LicenseStatus(False, "Serial invalido", hardware_id, 0)
def save(self, serial: str) -> None:
cleaned = serial.strip()
if not cleaned:
raise ValueError("Serial vazio.")
self.license_path.write_text(cleaned, encoding="utf-8")
class RuntimeState:
def __init__(self, base_dir: Path, default_password: str):
self.lock = threading.RLock()
self.config_store = JsonStore(base_dir / "config_nodes.json")
self.map_store = JsonStore(base_dir / "mapa_dispositivos.json")
self.config = normalize_config(self.config_store.load(build_default_config(default_password)), default_password)
self.mapa_dispositivos = self.map_store.load({})
self.dados_tempo_real: Dict[str, dict] = {}
self.status_nodes_web: Dict[str, str] = {}
self.restart_event = threading.Event()
self.stop_event = threading.Event()
self.save_config()
def snapshot(self) -> Tuple[dict, dict, dict, dict]:
with self.lock:
return (
dict(self.config),
dict(self.mapa_dispositivos),
dict(self.dados_tempo_real),
dict(self.status_nodes_web),
)
def save_config(self) -> None:
with self.lock:
self.config_store.save(self.config)
def trigger_restart(self) -> None:
self.restart_event.set()
def consume_restart(self) -> bool:
if self.restart_event.is_set():
self.restart_event.clear()
return True
return False
def authenticate(self, username: str, password: str) -> bool:
with self.lock:
if username != self.config["web_user"]:
return False
return check_password_hash(self.config["web_password_hash"], password)
def update_config(self, zabbix_server: str, hostname_zabbix: str, nodes: List[dict]) -> None:
with self.lock:
self.config["zabbix_server"] = zabbix_server.strip()
self.config["hostname_zabbix"] = hostname_zabbix.strip()
self.config["nodes"] = nodes
active_prefixes = [f"{node['ip']}_{node['unit']}_" for node in nodes]
self.mapa_dispositivos = {
key: value
for key, value in self.mapa_dispositivos.items()
if any(key.startswith(prefix) for prefix in active_prefixes)
}
self.config_store.save(self.config)
self.map_store.save(self.mapa_dispositivos)
self.restart_event.set()
def rename_device(self, address_key: str, label: str) -> bool:
with self.lock:
if address_key not in self.mapa_dispositivos:
return False
self.mapa_dispositivos[address_key]["label"] = label.strip()
self.map_store.save(self.mapa_dispositivos)
self.restart_event.set()
return True
def replace_node_devices(self, node: dict, discovered_map: dict) -> None:
prefix = f"{node['ip']}_{node['unit']}_"
with self.lock:
self.mapa_dispositivos = {
key: value for key, value in self.mapa_dispositivos.items() if not key.startswith(prefix)
}
self.mapa_dispositivos.update(discovered_map)
self.map_store.save(self.mapa_dispositivos)
self.restart_event.set()
class MonitorEngine:
def __init__(self, runtime_state: RuntimeState, license_service: LicenseService):
self.runtime_state = runtime_state
self.license_service = license_service
def start(self) -> None:
threading.Thread(target=self.run, daemon=True, name="monitor-engine").start()
def run(self) -> None:
while not self.runtime_state.stop_event.is_set():
license_status = self.license_service.verify()
if not license_status.valid:
with self.runtime_state.lock:
self.runtime_state.status_nodes_web = {"SISTEMA": "BLOQUEADO"}
self.runtime_state.dados_tempo_real = {}
time.sleep(RETRY_INTERVAL_SECONDS)
continue
self.runtime_state.consume_restart()
config, device_map, _, _ = self.runtime_state.snapshot()
hostname = config.get("hostname_zabbix", "").strip()
sender = self._build_sender(config.get("zabbix_server", "").strip())
self._send_discovery(sender, hostname, config.get("nodes", []), device_map)
while not self.runtime_state.stop_event.is_set():
if self.runtime_state.consume_restart():
break
self._poll_once(sender, hostname)
time.sleep(POLL_INTERVAL_SECONDS)
def _build_sender(self, server: str):
if not server:
return None
try:
return Sender(server=server)
except Exception as exc:
LOGGER.warning("Nao foi possivel iniciar sender do Zabbix: %s", exc)
return None
def _send_discovery(self, sender, hostname: str, nodes: List[dict], device_map: dict) -> None:
if not sender or not hostname or not device_map:
return
lld = {"data": []}
for info in device_map.values():
lld["data"].append({"{#PONTO_LABEL}": info["label"]})
for node in nodes:
lld["data"].append({"{#NODE_NOME}": node["nome"]})
try:
sender.send([ItemValue(hostname, "notifier.discovery", json.dumps(lld, ensure_ascii=False))])
except Exception as exc:
LOGGER.warning("Falha ao enviar discovery ao Zabbix: %s", exc)
def _poll_once(self, sender, hostname: str) -> None:
config, device_map, _, _ = self.runtime_state.snapshot()
nodes = config.get("nodes", [])
metrics: List[ItemValue] = []
latest_data: Dict[str, dict] = {}
node_statuses: Dict[str, str] = {}
for node in nodes:
node_metrics, node_data, node_status = self._poll_node(node, device_map, hostname)
metrics.extend(node_metrics)
latest_data.update(node_data)
node_statuses[node["nome"]] = node_status
with self.runtime_state.lock:
self.runtime_state.dados_tempo_real.update(latest_data)
self.runtime_state.status_nodes_web = node_statuses
if sender and hostname and metrics:
try:
sender.send(metrics)
except Exception as exc:
LOGGER.warning("Falha ao enviar metricas ao Zabbix: %s", exc)
def _poll_node(self, node: dict, device_map: dict, hostname: str):
client = ModbusClient(
host=node["ip"],
port=502,
unit_id=node["unit"],
auto_open=True,
timeout=1,
)
node_metrics: List[ItemValue] = []
node_data: Dict[str, dict] = {}
node_status = "Offline"
try:
alive = client.read_holding_registers(1, 1)
status_conn = 1 if alive is not None else 0
node_status = "Online" if status_conn else "Offline"
if hostname:
node_metrics.append(ItemValue(hostname, f"node.status[{node['nome']}]", status_conn))
if not status_conn:
return node_metrics, node_data, node_status
node_keys = [
key for key in device_map.keys() if key.startswith(f"{node['ip']}_{node['unit']}_")
]
node_addresses = [int(key.rsplit("_", 1)[-1]) for key in node_keys]
for start, end in contiguous_ranges(node_addresses):
registers = client.read_holding_registers(start, end - start + 1)
if not registers:
continue
for offset, value in enumerate(registers):
address = start + offset
key = f"{node['ip']}_{node['unit']}_{address}"
if key not in device_map:
continue
label = device_map[key]["label"]
node_data[key] = {
"label": label,
"status": value,
"node": node["nome"],
"last": time.strftime("%H:%M:%S"),
}
if hostname:
node_metrics.append(ItemValue(hostname, f"notifier.status[{label}]", value & 0xFF))
node_metrics.append(ItemValue(hostname, f"notifier.comm[{label}]", (value >> 8) & 0xFF))
except Exception as exc:
LOGGER.warning("Falha ao consultar node %s (%s): %s", node["nome"], node["ip"], exc)
finally:
client.close()
return node_metrics, node_data, node_status