diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..70ae8d1 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +VFM_DEFAULT_PASSWORD=admin +VFM_APP_SECRET= +VFM_LICENSE_MASTER_KEY= +VFM_ENV= +VFM_LOG_LEVEL=INFO +PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5bc35a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.pytest_cache/ +config_nodes.json +mapa_dispositivos.json +license.key +app_secret.key diff --git a/README.md b/README.md index 3a911e6..449a56c 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,97 @@ -# 🔥 V-Fire Monitor - Notifier Master (v1.0) +# V-Fire Monitor -O **V-Fire Monitor** é uma solução robusta de monitoramento e gestão para centrais de alarme de incêndio da linha **Notifier (NFS-320, NFS-640, NFS-3030)**. Desenvolvido para a **Voltec**, o software atua como um gateway inteligente entre o protocolo Modbus/TCP das placas BACNET-GW-3 e o sistema de monitoramento Zabbix, oferecendo uma interface web intuitiva e um sistema de licenciamento proprietário. +Aplicacao Flask para monitoramento de centrais Notifier via Modbus/TCP, com painel web, descoberta de pontos, integracao com Zabbix e licenciamento por hardware. ---- +## O que mudou nesta refatoracao -## 🚀 Funcionalidades Principais +- Persistencia JSON com escrita atomica e migracao de configuracao legada. +- Login com hash de senha em vez de senha em texto puro no arquivo de configuracao. +- Chave secreta do Flask persistida localmente, sem valor fixo no codigo. +- Validacao de payloads da API e respostas de erro consistentes. +- Logging basico para diagnostico, no lugar de falhas silenciosas. +- Polling Modbus e discovery por blocos, reduzindo chamadas individuais. +- Frontend com tratamento de erro e textos corrigidos. +- Gerador de licenca com argumentos de linha de comando e validacao de data. -* **Monitoramento Multi-Node:** Gerenciamento de múltiplas centrais simultaneamente via IP/Modbus. -* **Auto-Discovery Ultra:** Varredura automática de endereços Modbus (registradores 1-600) para identificação instantânea de dispositivos. -* **Integração Nativa Zabbix:** * Envio de **LLD (Low Level Discovery)** para autocadastro de itens. - * Monitoramento de **Status de Evento** (Alarme, Normal, Removido). - * Monitoramento de **Integridade de Comunicação** (Link de dados do ponto). -* **Gestão de Licenciamento:** * Proteção por **Hardware ID (HWID)** vinculada à máquina física. - * Controle de expiração por data com contador regressivo no painel. -* **Persistência de Dados:** Nomes personalizados de sensores são preservados no banco de dados local (`.json`), mesmo após novos scans. -* **Interface Web Profissional:** Painel em *Dark Mode* com indicadores visuais coloridos e status em tempo real. +## Estrutura ---- +- `monitor.py`: entrypoint simples da aplicacao Flask. +- `vfire_monitor/__init__.py`: app factory e bootstrap da aplicacao. +- `vfire_monitor/core.py`: regras de negocio, persistencia, licenca e engine de monitoramento. +- `vfire_monitor/routes.py`: rotas web e APIs. +- `generator.py`: gerador de serial de licenca. +- `templates/`: telas do login e dashboard. +- `tests/`: suite inicial de testes automatizados. +- `config_nodes.json`: configuracao persistida da aplicacao. +- `mapa_dispositivos.json`: mapa de dispositivos descobertos. +- `license.key`: serial instalado localmente. +- `app_secret.key`: segredo de sessao gerado automaticamente na primeira execucao. -## 📂 Estrutura do Projeto +## Requisitos -* `monitor.py`: Core do sistema. Gerencia o loop de leitura Modbus, o servidor Flask e o envio de métricas via Zabbix Sender. -* `generator.py`: Ferramenta administrativa da Voltec para gerar seriais de ativação criptografados. -* `templates/`: - * `login.html`: Interface de autenticação segura. - * `index.html`: Dashboard principal com monitoramento de nós, sensores e licença. -* `config_nodes.json`: Arquivo de persistência para IPs das centrais e endereços do servidor Zabbix. -* `mapa_dispositivos.json`: Mapeamento detalhado de cada endereço Modbus e seu rótulo personalizado. +- Python 3.10+ +- Conectividade com as centrais via Modbus/TCP +- Acesso ao servidor Zabbix, quando a integracao estiver habilitada ---- +Instalacao: -## 🛠️ Instalação e Requisitos - -### Dependências Python ```bash -pip install flask flask-login pyModbusTCP zabbix-utils cryptography \ No newline at end of file +pip install -r requirements.txt +``` + +## Variaveis de ambiente + +Veja `.env.example`. + +As principais: + +- `VFM_DEFAULT_PASSWORD`: senha inicial do usuario `admin` na primeira carga do sistema. +- `VFM_APP_SECRET`: opcional, substitui o segredo salvo em `app_secret.key`. +- `VFM_LICENSE_MASTER_KEY`: chave mestre do licenciamento. Em producao, use esta variavel e remova a dependencia da chave legada. +- `VFM_ENV`: use `production` para obrigar `VFM_LICENSE_MASTER_KEY` no startup. +- `VFM_LOG_LEVEL`: nivel de log, por exemplo `INFO` ou `DEBUG`. +- `PORT`: porta HTTP da aplicacao. + +## Execucao + +```bash +python monitor.py +``` + +O sistema sobe em `http://0.0.0.0:8080` por padrao. + +## Geracao de licenca + +Modo interativo: + +```bash +python generator.py +``` + +Modo por argumentos: + +```bash +python generator.py --hwid "UUID-DO-CLIENTE" --cliente "Cliente" --expira 2026-12-31 +``` + +## Observacoes operacionais + +- A senha do painel fica armazenada como hash em `config_nodes.json`. +- Se existir configuracao antiga com `web_password`, ela e migrada automaticamente para `web_password_hash`. +- Em ambiente de desenvolvimento, o sistema ainda aceita a chave de licenca legada embutida para manter compatibilidade. +- Em ambiente de producao (`VFM_ENV=production`), `VFM_LICENSE_MASTER_KEY` passa a ser obrigatoria e o sistema falha no startup sem ela. +- Bootstrap continua sendo carregado via CDN. Se o ambiente nao tiver acesso externo, copie os assets localmente e ajuste os templates. + +## Testes + +Executar: + +```bash +pytest +``` + +## Proximos passos recomendados + +- Adicionar testes cobrindo polling Modbus e integracao com Zabbix com doubles dedicados. +- Separar configuracao e logging em modulos proprios se a aplicacao continuar crescendo. +- Trocar Bootstrap via CDN por assets locais se o ambiente alvo nao tiver acesso externo. diff --git a/generator.py b/generator.py index 06aa5e8..a5b63eb 100644 --- a/generator.py +++ b/generator.py @@ -1,16 +1,58 @@ -from cryptography.fernet import Fernet -import json - -# CHAVE MESTRA - DEVE SER A MESMA NO MONITOR.PY -MASTER_KEY = b'vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4=' - -def gerar_serial(hardware_id, cliente, data_expiracao): - f = Fernet(MASTER_KEY) - dados = {"h": hardware_id.strip(), "c": cliente, "e": data_expiracao} - return f.encrypt(json.dumps(dados).encode()).decode() - -print("--- GERADOR DE LICENÇA VOLTEC ---") -hwid = input("Hardware ID do cliente: ") -nome = input("Nome do Cliente: ") -validade = input("Validade (AAAA-MM-DD): ") -print(f"\nSERIAL:\n{gerar_serial(hwid, nome, validade)}\n") \ No newline at end of file +import argparse +import json +import os +from datetime import datetime + +from cryptography.fernet import Fernet + + +LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4=" + + +def load_master_key() -> bytes: + env_key = os.getenv("VFM_LICENSE_MASTER_KEY") + if env_key: + return env_key.encode("utf-8") + return LEGACY_LICENSE_KEY + + +def parse_args(): + parser = argparse.ArgumentParser(description="Gerador de licenca do V-Fire Monitor") + parser.add_argument("--hwid", help="Hardware ID do cliente") + parser.add_argument("--cliente", help="Nome do cliente") + parser.add_argument("--expira", help="Data de expiracao no formato AAAA-MM-DD") + return parser.parse_args() + + +def ask_if_missing(value: str, prompt: str) -> str: + return value if value else input(prompt).strip() + + +def validate_expiration(date_text: str) -> str: + datetime.strptime(date_text, "%Y-%m-%d") + return date_text + + +def gerar_serial(hardware_id: str, cliente: str, data_expiracao: str) -> str: + fernet = Fernet(load_master_key()) + payload = {"h": hardware_id.strip(), "c": cliente.strip(), "e": validate_expiration(data_expiracao)} + return fernet.encrypt(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("utf-8") + + +def main(): + args = parse_args() + + hardware_id = ask_if_missing(args.hwid, "Hardware ID do cliente: ") + cliente = ask_if_missing(args.cliente, "Nome do cliente: ") + expiracao = ask_if_missing(args.expira, "Validade (AAAA-MM-DD): ") + + if not hardware_id: + raise SystemExit("Hardware ID obrigatorio.") + if not cliente: + raise SystemExit("Nome do cliente obrigatorio.") + + print(gerar_serial(hardware_id, cliente, expiracao)) + + +if __name__ == "__main__": + main() diff --git a/monitor.py b/monitor.py index 1864291..53d6f93 100644 --- a/monitor.py +++ b/monitor.py @@ -1,241 +1,10 @@ -import time, json, os, threading, subprocess -from datetime import datetime -from flask import Flask, render_template, jsonify, request, redirect, url_for -from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user -from pyModbusTCP.client import ModbusClient -from zabbix_utils import Sender, ItemValue -from cryptography.fernet import Fernet - -app = Flask(__name__) -app.secret_key = 'voltec_master_final_ultra_v6' - -# --- 1. DEFINIÇÕES GLOBAIS E PERSISTÊNCIA (Devem vir antes das rotas) --- -ARQUIVO_CONFIG = 'config_nodes.json' -ARQUIVO_MAPA = 'mapa_dispositivos.json' -ARQUIVO_LICENSA = 'license.key' -MASTER_KEY = b'vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4=' - -def carregar_dados(arquivo, padrao): - if os.path.exists(arquivo): - try: - with open(arquivo, 'r') as f: return json.load(f) - except: return padrao - return padrao - -def salvar_dados(arquivo, dados): - with open(arquivo, 'w') as f: json.dump(dados, f, indent=4) - -# Carregamos a config aqui para o login não dar erro -config_global = carregar_dados(ARQUIVO_CONFIG, { - "zabbix_server": "172.16.33.6", - "hostname_zabbix": "NFS-320", - "web_user": "admin", - "web_password": "admin", - "nodes": [] -}) -mapa_dispositivos = carregar_dados(ARQUIVO_MAPA, {}) -dados_tempo_real = {} -status_nodes_web = {} -deve_reiniciar = False - -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"} -} - -# --- 2. GESTÃO DE ACESSO --- -login_manager = LoginManager() -login_manager.init_app(app) -login_manager.login_view = 'login' - -class User(UserMixin): - def __init__(self, id): self.id = id - -@login_manager.user_loader -def load_user(user_id): return User(user_id) - -# --- 3. AUXILIARES E LICENCIAMENTO (VERSÃO COM DIAS) --- -def get_status_info(valor): - lsb = valor & 0xFF - return MAPA_STATUS_BASE.get(valor, MAPA_STATUS_BASE.get(lsb, {"label": f"ST {valor}", "color": "#6c757d"})) - -def get_hardware_id(): - try: - if os.name == 'nt': - cmd = 'powershell "(Get-CimInstance Win32_ComputerSystemProduct).UUID.Trim()"' - return subprocess.check_output(cmd, shell=True).decode().strip() - return subprocess.check_output('cat /etc/machine-id', shell=True).decode().strip()[:32] - except: return "ID-ERRO-VOLTEC-001" - -def verificar_licenca(): - if not os.path.exists(ARQUIVO_LICENSA): - return False, "Licença ausente", get_hardware_id(), 0 - try: - f = Fernet(MASTER_KEY) - with open(ARQUIVO_LICENSA, 'r') as file: - serial = file.read().strip() - - dados = json.loads(f.decrypt(serial.encode()).decode()) - - if dados['h'].strip() != get_hardware_id().strip(): - return False, "HWID Incompatível", get_hardware_id(), 0 - - expira = datetime.strptime(dados['e'], '%Y-%m-%d') - dias_restantes = (expira - datetime.now()).days - - if dias_restantes < 0: - return False, "Expirada", get_hardware_id(), 0 - - return True, f"Licenciado para {dados['c']}", get_hardware_id(), dias_restantes - except: - return False, "Serial Inválido", get_hardware_id(), 0 - -# --- 4. MOTOR MODBUS E BACKGROUND --- -def realizar_discovery_node(node, mapa_atual): - client = ModbusClient(host=node['ip'], port=502, unit_id=int(node['unit']), auto_open=True, timeout=0.5) - mapa_node = {} - for addr in range(1, 601): - res = client.read_holding_registers(addr, 1) - if res and res[0] not in [0, 255, 65535]: - key = f"{node['ip']}_{node['unit']}_{addr}" - if key in mapa_atual: label = mapa_atual[key]['label'] - else: - tipo = 'D' if addr < 160 else ('M' if addr < 350 else 'P') - label = f"{node['nome']}-{tipo}{addr:03}" - mapa_node[key] = {"label": label, "status": res[0], "node_nome": node['nome']} - client.close() - return mapa_node - -def monitorar_background(): - global dados_tempo_real, deve_reiniciar, config_global, mapa_dispositivos, status_nodes_web - while True: - licenciado, _, _, _ = verificar_licenca() - if not licenciado: - status_nodes_web = {"SISTEMA": "BLOQUEADO"} - time.sleep(5); continue - - config_global = carregar_dados(ARQUIVO_CONFIG, config_global) - mapa_dispositivos = carregar_dados(ARQUIVO_MAPA, mapa_dispositivos) - sender = Sender(server=config_global['zabbix_server']) - hostname = config_global['hostname_zabbix'] - - # Discovery LLD (Zabbix re-cadastra nomes se houver mudança) - if mapa_dispositivos: - lld = {"data": []} - for k, info in mapa_dispositivos.items(): lld["data"].append({"{#PONTO_LABEL}": info["label"]}) - for n in config_global['nodes']: lld["data"].append({"{#NODE_NOME}": n["nome"]}) - try: sender.send([ItemValue(hostname, "notifier.discovery", json.dumps(lld))]) - except: pass - - while not deve_reiniciar: - metrics = [] - for node in config_global['nodes']: - c = ModbusClient(host=node['ip'], port=502, unit_id=int(node['unit']), auto_open=True, timeout=1) - res = c.read_holding_registers(1, 1) - status_conn = 1 if res is not None else 0 - status_nodes_web[node['nome']] = "Online" if status_conn == 1 else "Offline" - metrics.append(ItemValue(hostname, f'node.status[{node["nome"]}]', status_conn)) - if status_conn == 1: - for key, info in list(mapa_dispositivos.items()): - if key.startswith(f"{node['ip']}_{node['unit']}_"): - addr = int(key.split('_')[-1]) - r = c.read_holding_registers(addr, 1) - if r: - val = r[0] - dados_tempo_real[key] = {"label": info['label'], "status": val, "node": node['nome'], "last": time.strftime('%H:%M:%S')} - metrics.append(ItemValue(hostname, f'notifier.status[{info["label"]}]', val & 0xFF)) - metrics.append(ItemValue(hostname, f'notifier.comm[{info["label"]}]', (val >> 8) & 0xFF)) - c.close() - if metrics: - try: sender.send(metrics) - except: pass - time.sleep(2) - deve_reiniciar = False - -# --- 5. ROTAS --- -@app.route('/login', methods=['GET', 'POST']) -def login(): - if request.method == 'POST': - # Aqui o config_global agora existe! - if request.form.get('username') == config_global['web_user'] and \ - request.form.get('password') == config_global['web_password']: - login_user(User(config_global['web_user'])) - return redirect(url_for('index')) - return render_template('login.html') - -@app.route('/') -@login_required -def index(): - # Agora recebemos 4 valores da função - lic, msg, hwid, dias = verificar_licenca() - return render_template('index.html', - config=config_global, - licenciado=lic, - lic_msg=msg, - hwid=hwid, - dias_restantes=dias) - -@app.route('/api/nodes', methods=['POST']) -@login_required -def api_nodes(): - global deve_reiniciar, config_global, mapa_dispositivos - data = request.json - config_global.update({'nodes': data['nodes'], 'zabbix_server': data['zabbix_server'], 'hostname_zabbix': data['hostname_zabbix']}) - salvar_dados(ARQUIVO_CONFIG, config_global) - ips_ativos = [f"{n['ip']}_{n['unit']}_" for n in data['nodes']] - mapa_dispositivos = {k: v for k, v in mapa_dispositivos.items() if any(k.startswith(p) for p in ips_ativos)} - salvar_dados(ARQUIVO_MAPA, mapa_dispositivos) - deve_reiniciar = True - return jsonify({"status": "success"}) - -@app.route('/api/rename', methods=['POST']) -@login_required -def api_rename(): - global deve_reiniciar - data = request.json - if data['addr'] in mapa_dispositivos: - mapa_dispositivos[data['addr']]['label'] = data['label'] - salvar_dados(ARQUIVO_MAPA, mapa_dispositivos) - deve_reiniciar = True # Força envio do novo nome ao Zabbix - return jsonify({"status": "success"}) - return jsonify({"status": "error"}), 404 - -@app.route('/api/license', methods=['POST']) -def api_license(): - global deve_reiniciar - with open(ARQUIVO_LICENSA, 'w') as f: f.write(request.json['serial'].strip()) - deve_reiniciar = True - return jsonify({"status": "success"}) - -@app.route('/api/rescan_node', methods=['POST']) -@login_required -def api_rescan(): - global deve_reiniciar, mapa_dispositivos - node_data = request.json - res_mapa = realizar_discovery_node(node_data, mapa_dispositivos) - prefixo = f"{node_data['ip']}_{node_data['unit']}_" - mapa_dispositivos = {k: v for k, v in mapa_dispositivos.items() if not k.startswith(prefixo)} - mapa_dispositivos.update(res_mapa) - salvar_dados(ARQUIVO_MAPA, mapa_dispositivos) - deve_reiniciar = True - return jsonify({"status": "success"}) - -@app.route('/api/data') -@login_required -def api_data(): - lista = [] - for key, info in mapa_dispositivos.items(): - tr = dados_tempo_real.get(key, {"status": 0, "last": "--:--:--", "node": info.get('node_nome', '...')}) - st_info = get_status_info(tr['status']) - lista.append({"key": key, "label": info['label'], "node": tr.get('node', '...'), "status_text": st_info['label'], "color": st_info['color'], "last": tr['last']}) - return jsonify({"sensores": lista, "nodes_status": status_nodes_web}) - -@app.route('/logout') -def logout(): logout_user(); return redirect(url_for('login')) - -if __name__ == "__main__": - threading.Thread(target=monitorar_background, daemon=True).start() - app.run(host='0.0.0.0', port=8080) \ No newline at end of file +import os + +from vfire_monitor import create_app + + +app = create_app() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8080")), debug=False) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce79877 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask>=3.0,<4.0 +Flask-Login>=0.6,<1.0 +pyModbusTCP>=0.3,<1.0 +zabbix-utils>=2.0,<3.0 +cryptography>=42.0,<45.0 +pytest>=8.0,<9.0 diff --git a/templates/index.html b/templates/index.html index ae2e9ca..a50b821 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,169 +1,429 @@ - - - - - Notifier Master - Voltec - - - - - -
-
-
Varrendo central (1-600)...
-
- -
-
-

🔥 Notifier Voltec

-
- - Sair -
-
-
- -
- {% if not licenciado %} -
-
-

🔒 Sistema Bloqueado

-

ID: {{ hwid }}

- - -
-
- {% else %} -
-
- - ⏳ {{ dias_restantes }} dias restantes - -
- -
- - {{ lic_msg }} - -
-
- -
- {% endif %} -
- - - - - - - \ No newline at end of file + + + + + + V-Fire Monitor + + + + +
+
+
+

Executando varredura Modbus...

+
+
+ +
+
+
+
Voltec
+

V-Fire Monitor

+
+
+ + Sair +
+
+
+ +
+
+ + {% if not licenciado %} +
+
+
+

Sistema bloqueado

+

Ative a licenca deste equipamento para liberar o monitoramento.

+

Hardware ID

+ {{ hwid }} + + +
+
+
+ {% else %} +
+
+
Licenca
+
{{ lic_msg }}
+
+ + {{ dias_restantes }} dias restantes + +
+ +
+ {% endif %} +
+ + + + + + + diff --git a/templates/login.html b/templates/login.html index 6ec1ecf..ee374da 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,22 +1,55 @@ - - - - Login - Notifier - - - - -
-

Acesso Notifier

-
-
-
- - {% with messages = get_flashed_messages() %}{% if messages %}
{{ messages[0] }}
{% endif %}{% endwith %} -
-
- - \ No newline at end of file + + + + + + Login | V-Fire Monitor + + + + +
+
Voltec
+

Acesso ao V-Fire Monitor

+ + {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} + +
+
+ + +
+
+ + +
+ +
+
+ + diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..afb1d8d --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,119 @@ +import json +from pathlib import Path + +import pytest +from cryptography.fernet import Fernet +from werkzeug.security import check_password_hash + +from vfire_monitor import create_app +from vfire_monitor.core import ( + LicenseService, + contiguous_ranges, + load_license_key, + normalize_config, + validate_node_payload, +) + + +def test_validate_node_payload_normalizes_fields(): + node = validate_node_payload({"nome": " Central A ", "ip": " 10.0.0.1 ", "unit": "3"}) + assert node == {"nome": "Central A", "ip": "10.0.0.1", "unit": 3} + + +def test_validate_node_payload_rejects_invalid_unit(): + with pytest.raises(ValueError): + validate_node_payload({"nome": "Central A", "ip": "10.0.0.1", "unit": 999}) + + +def test_normalize_config_migrates_legacy_password(): + config = normalize_config( + { + "web_user": "admin", + "web_password": "segredo", + "nodes": [{"nome": "Node 1", "ip": "1.1.1.1", "unit": 1}], + }, + default_password="admin", + ) + + assert "web_password" not in config + assert check_password_hash(config["web_password_hash"], "segredo") + assert config["nodes"][0]["nome"] == "Node 1" + + +def test_contiguous_ranges_splits_large_blocks(): + ranges = contiguous_ranges(list(range(1, 125))) + assert ranges == [(1, 120), (121, 124)] + + +def test_license_service_validates_serial(tmp_path, monkeypatch): + monkeypatch.setattr("vfire_monitor.core.get_hardware_id", lambda: "HW-123") + key = Fernet.generate_key() + payload = {"h": "HW-123", "c": "Cliente", "e": "2099-01-01"} + serial = Fernet(key).encrypt(json.dumps(payload).encode("utf-8")).decode("utf-8") + license_path = tmp_path / "license.key" + license_path.write_text(serial, encoding="utf-8") + + service = LicenseService(license_path, key) + status = service.verify() + + assert status.valid is True + assert "Cliente" in status.message + + +def test_load_license_key_requires_env_in_production(monkeypatch): + monkeypatch.delenv("VFM_LICENSE_MASTER_KEY", raising=False) + with pytest.raises(RuntimeError): + load_license_key(require_env=True) + + +@pytest.fixture +def app(tmp_path, monkeypatch): + monkeypatch.setenv("VFM_DEFAULT_PASSWORD", "admin") + monkeypatch.setenv("VFM_LICENSE_MASTER_KEY", Fernet.generate_key().decode("utf-8")) + application = create_app(base_dir=tmp_path, start_monitor=False) + application.config["TESTING"] = True + + runtime_state = application.extensions["runtime_state"] + runtime_state.config["web_user"] = "admin" + runtime_state.config["web_password_hash"] = normalize_config({}, "admin")["web_password_hash"] + runtime_state.save_config() + return application + + +@pytest.fixture +def client(app): + return app.test_client() + + +def login(client): + return client.post("/login", data={"username": "admin", "password": "admin"}, follow_redirects=False) + + +def test_login_success(client): + response = login(client) + assert response.status_code == 302 + + +def test_api_nodes_requires_auth(client): + response = client.post("/api/nodes", json={}) + assert response.status_code == 302 + + +def test_api_nodes_updates_configuration(client, app): + login(client) + response = client.post( + "/api/nodes", + json={ + "zabbix_server": "127.0.0.1", + "hostname_zabbix": "HOST-01", + "nodes": [{"nome": "Central A", "ip": "10.0.0.5", "unit": 3}], + }, + ) + + assert response.status_code == 200 + assert response.get_json()["status"] == "success" + + config_path = Path(app.extensions["runtime_state"].config_store.path) + config = json.loads(config_path.read_text(encoding="utf-8")) + assert config["hostname_zabbix"] == "HOST-01" + assert config["nodes"][0]["nome"] == "Central A" diff --git a/vfire_monitor/__init__.py b/vfire_monitor/__init__.py new file mode 100644 index 0000000..39903cd --- /dev/null +++ b/vfire_monitor/__init__.py @@ -0,0 +1,68 @@ +import logging +import os +from pathlib import Path + +from flask import Flask +from flask_login import LoginManager + +from .core import ( + LicenseService, + MonitorEngine, + RuntimeState, + User, + ensure_secret_key, + load_license_key, +) +from .routes import register_routes + + +def configure_logging() -> None: + logging.basicConfig( + level=os.getenv("VFM_LOG_LEVEL", "INFO").upper(), + format="%(asctime)s [%(levelname)s] %(message)s", + ) + + +def is_production_mode() -> bool: + value = os.getenv("VFM_ENV", "").strip().lower() + return value in {"prod", "production"} + + +def create_app(base_dir: str | Path | None = None, start_monitor: bool = True) -> Flask: + configure_logging() + + resolved_base_dir = Path(base_dir) if base_dir else Path(__file__).resolve().parent.parent + secret_path = resolved_base_dir / "app_secret.key" + default_password = os.getenv("VFM_DEFAULT_PASSWORD", "admin") + + app = Flask( + __name__, + template_folder=str(resolved_base_dir / "templates"), + ) + app.secret_key = os.getenv("VFM_APP_SECRET") or ensure_secret_key(secret_path) + + runtime_state = RuntimeState(resolved_base_dir, default_password) + license_service = LicenseService( + resolved_base_dir / "license.key", + load_license_key(require_env=is_production_mode()), + ) + + app.extensions["runtime_state"] = runtime_state + app.extensions["license_service"] = license_service + + login_manager = LoginManager() + login_manager.init_app(app) + login_manager.login_view = "login" + + @login_manager.user_loader + def load_user(user_id): + return User(user_id) + + register_routes(app) + + if start_monitor: + monitor_engine = MonitorEngine(runtime_state, license_service) + app.extensions["monitor_engine"] = monitor_engine + monitor_engine.start() + + return app diff --git a/vfire_monitor/core.py b/vfire_monitor/core.py new file mode 100644 index 0000000..2ba693d --- /dev/null +++ b/vfire_monitor/core.py @@ -0,0 +1,494 @@ +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 diff --git a/vfire_monitor/routes.py b/vfire_monitor/routes.py new file mode 100644 index 0000000..d154d05 --- /dev/null +++ b/vfire_monitor/routes.py @@ -0,0 +1,150 @@ +from flask import current_app, flash, jsonify, redirect, render_template, request, url_for +from flask_login import current_user, login_required, login_user, logout_user + +from .core import LicenseService, RuntimeState, User, discover_node, get_status_info, validate_node_payload + + +def get_runtime_state() -> RuntimeState: + return current_app.extensions["runtime_state"] + + +def get_license_service() -> LicenseService: + return current_app.extensions["license_service"] + + +def json_error(message: str, status_code: int = 400): + return jsonify({"status": "error", "message": message}), status_code + + +def register_routes(app): + @app.route("/login", methods=["GET", "POST"]) + def login(): + if current_user.is_authenticated: + return redirect(url_for("index")) + + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if get_runtime_state().authenticate(username, password): + login_user(User(username)) + return redirect(url_for("index")) + flash("Usuario ou senha invalidos.") + return render_template("login.html") + + @app.route("/") + @login_required + def index(): + config, _, _, _ = get_runtime_state().snapshot() + license_status = get_license_service().verify() + return render_template( + "index.html", + config=config, + licenciado=license_status.valid, + lic_msg=license_status.message, + hwid=license_status.hardware_id, + dias_restantes=license_status.days_remaining, + ) + + @app.route("/api/nodes", methods=["POST"]) + @login_required + def api_nodes(): + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return json_error("JSON invalido.") + + try: + zabbix_server = str(payload.get("zabbix_server", "")).strip() + hostname_zabbix = str(payload.get("hostname_zabbix", "")).strip() + nodes = [validate_node_payload(node) for node in payload.get("nodes", [])] + except ValueError as exc: + return json_error(str(exc)) + + if not zabbix_server: + return json_error("Zabbix Server e obrigatorio.") + if not hostname_zabbix: + return json_error("Hostname Zabbix e obrigatorio.") + + get_runtime_state().update_config(zabbix_server, hostname_zabbix, nodes) + return jsonify({"status": "success"}) + + @app.route("/api/rename", methods=["POST"]) + @login_required + def api_rename(): + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return json_error("JSON invalido.") + + address_key = str(payload.get("addr", "")).strip() + label = str(payload.get("label", "")).strip() + + if not address_key: + return json_error("Endereco do dispositivo e obrigatorio.") + if not label: + return json_error("Nome do dispositivo e obrigatorio.") + + if not get_runtime_state().rename_device(address_key, label): + return json_error("Dispositivo nao encontrado.", 404) + + return jsonify({"status": "success"}) + + @app.route("/api/license", methods=["POST"]) + def api_license(): + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return json_error("JSON invalido.") + + try: + get_license_service().save(str(payload.get("serial", ""))) + except ValueError as exc: + return json_error(str(exc)) + + get_runtime_state().trigger_restart() + return jsonify({"status": "success"}) + + @app.route("/api/rescan_node", methods=["POST"]) + @login_required + def api_rescan(): + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return json_error("JSON invalido.") + + try: + node = validate_node_payload(payload) + except ValueError as exc: + return json_error(str(exc)) + + _, current_map, _, _ = get_runtime_state().snapshot() + discovered_map = discover_node(node, current_map) + get_runtime_state().replace_node_devices(node, discovered_map) + return jsonify({"status": "success", "count": len(discovered_map)}) + + @app.route("/api/data") + @login_required + def api_data(): + _, device_map, realtime_data, node_statuses = get_runtime_state().snapshot() + sensors = [] + + for key, info in device_map.items(): + data = realtime_data.get( + key, + {"status": 0, "last": "--:--:--", "node": info.get("node_nome", "...")}, + ) + status_info = get_status_info(int(data["status"])) + sensors.append( + { + "key": key, + "label": info["label"], + "node": data.get("node", "..."), + "status_text": status_info["label"], + "color": status_info["color"], + "last": data["last"], + } + ) + + return jsonify({"sensores": sensors, "nodes_status": node_statuses}) + + @app.route("/logout") + @login_required + def logout(): + logout_user() + return redirect(url_for("login"))