import json import logging import os import threading import time from dataclasses import dataclass from datetime import datetime from ipaddress import ip_address 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": [], } DEFAULT_MODBUS_PORT = 502 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 write_private_text(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") if os.name != "nt": path.chmod(0o600) 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") write_private_text(secret_path, secret) 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 {}) legacy_password = str((data or {}).get("web_password", "")).strip() if legacy_password: config["web_password_hash"] = generate_password_hash(legacy_password) config.pop("web_password", None) config["web_user"] = str(config.get("web_user", DEFAULT_CONFIG_TEMPLATE["web_user"])).strip() or DEFAULT_CONFIG_TEMPLATE[ "web_user" ] 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") allow_legacy = os.getenv("VFM_ALLOW_LEGACY_LICENSE_KEY", os.getenv("ALLOW_LEGACY_LICENSE_KEY", "0")) == "1" if env_key: return env_key.encode("utf-8") if require_env and not allow_legacy: 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: ip_address(ip) except ValueError: raise ValueError(f"IP invalido para node {nome}.") from None try: unit = int(data.get("unit")) except (TypeError, ValueError): raise ValueError(f"Unit invalido para node {nome}.") from None try: port = int(data.get("port", DEFAULT_MODBUS_PORT)) except (TypeError, ValueError): raise ValueError(f"Porta invalida para node {nome}.") from None if unit < 0 or unit > 255: raise ValueError(f"Unit fora do intervalo para node {nome}.") if port < 1 or port > 65535: raise ValueError(f"Porta fora do intervalo para node {nome}.") return {"nome": nome, "ip": ip, "unit": unit, "port": port} def validate_login_payload(username: str, password: str) -> tuple[str, str]: normalized_username = username.strip() if not normalized_username: raise ValueError("Usuario e obrigatorio.") if len(normalized_username) > 64: raise ValueError("Usuario excede o limite de 64 caracteres.") if len(password) < 8: raise ValueError("Senha deve ter ao menos 8 caracteres.") if len(password) > 128: raise ValueError("Senha excede o limite de 128 caracteres.") return normalized_username, password 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=node.get("port", DEFAULT_MODBUS_PORT), 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 install(self, serial: str) -> LicenseStatus: cleaned = serial.strip() if not cleaned: raise ValueError("Serial vazio.") try: payload = json.loads(self.fernet.decrypt(cleaned.encode("utf-8")).decode("utf-8")) licensed_hwid = str(payload["h"]).strip() hardware_id = get_hardware_id().strip() if licensed_hwid != hardware_id: raise ValueError("HWID incompativel com este equipamento.") expires_at = datetime.strptime(str(payload["e"]).strip(), "%Y-%m-%d") days_remaining = (expires_at.date() - datetime.now().date()).days if days_remaining < 0: raise ValueError("Licenca expirada.") except InvalidToken: raise ValueError("Serial invalido.") from None except (KeyError, json.JSONDecodeError, OSError, ValueError): raise write_private_text(self.license_path, cleaned) return LicenseStatus(True, f"Licenciado para {payload['c']}", hardware_id, days_remaining) @dataclass class MonitorStatus: running: bool iteration: int last_cycle_started_at: str | None last_cycle_completed_at: str | None last_error: str | None last_license_message: str | None 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.monitor_status = MonitorStatus( running=False, iteration=0, last_cycle_started_at=None, last_cycle_completed_at=None, last_error=None, last_license_message=None, ) 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 get_monitor_status(self) -> MonitorStatus: with self.lock: return MonitorStatus(**self.monitor_status.__dict__) def save_config(self) -> None: with self.lock: self.config_store.save(self.config) def trigger_restart(self) -> None: self.restart_event.set() def stop(self) -> None: self.stop_event.set() 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_credentials(self, username: str, password: str) -> None: normalized_username, normalized_password = validate_login_payload(username, password) with self.lock: self.config["web_user"] = normalized_username self.config["web_password_hash"] = generate_password_hash(normalized_password) self.config_store.save(self.config) 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() def update_monitor_status( self, *, running: bool | None = None, iteration: int | None = None, last_cycle_started_at: str | None = None, last_cycle_completed_at: str | None = None, last_error: str | None = None, last_license_message: str | None = None, ) -> None: with self.lock: if running is not None: self.monitor_status.running = running if iteration is not None: self.monitor_status.iteration = iteration if last_cycle_started_at is not None: self.monitor_status.last_cycle_started_at = last_cycle_started_at if last_cycle_completed_at is not None: self.monitor_status.last_cycle_completed_at = last_cycle_completed_at if last_error is not None: self.monitor_status.last_error = last_error if last_license_message is not None: self.monitor_status.last_license_message = last_license_message class MonitorEngine: def __init__(self, runtime_state: RuntimeState, license_service: LicenseService): self.runtime_state = runtime_state self.license_service = license_service self.thread: threading.Thread | None = None def start(self) -> None: if self.thread and self.thread.is_alive(): return self.thread = threading.Thread(target=self.run, daemon=True, name="monitor-engine") self.thread.start() def stop(self, timeout: float = 5.0) -> None: self.runtime_state.stop() if self.thread and self.thread.is_alive(): self.thread.join(timeout=timeout) def run(self) -> None: self.runtime_state.update_monitor_status(running=True, last_error="") iteration = 0 while not self.runtime_state.stop_event.is_set(): iteration += 1 self.runtime_state.update_monitor_status( iteration=iteration, last_cycle_started_at=datetime.now().isoformat(timespec="seconds"), last_error="", ) license_status = self.license_service.verify() self.runtime_state.update_monitor_status(last_license_message=license_status.message) if not license_status.valid: with self.runtime_state.lock: self.runtime_state.status_nodes_web = {"SISTEMA": "BLOQUEADO"} self.runtime_state.dados_tempo_real = {} self.runtime_state.update_monitor_status( last_cycle_completed_at=datetime.now().isoformat(timespec="seconds"), ) self.runtime_state.stop_event.wait(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) self.runtime_state.update_monitor_status( last_cycle_completed_at=datetime.now().isoformat(timespec="seconds"), ) self.runtime_state.stop_event.wait(POLL_INTERVAL_SECONDS) self.runtime_state.update_monitor_status(running=False) 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) self.runtime_state.update_monitor_status(last_error=str(exc)) def _poll_node(self, node: dict, device_map: dict, hostname: str): client = ModbusClient( host=node["ip"], port=node.get("port", DEFAULT_MODBUS_PORT), 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) self.runtime_state.update_monitor_status(last_error=f"{node['nome']}: {exc}") finally: client.close() return node_metrics, node_data, node_status