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