Add production installer and observability tooling

This commit is contained in:
2026-05-07 12:11:15 -03:00
parent a19ac9f00c
commit 5d92a6c99a
30 changed files with 3493 additions and 1015 deletions
+73 -36
View File
@@ -1,68 +1,105 @@
import atexit
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",
)
from werkzeug.middleware.proxy_fix import ProxyFix
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 env_flag(name: str, default: bool = False) -> bool:
raw_value = os.getenv(name)
if raw_value is None:
return default
return raw_value.strip().lower() in {"1", "true", "yes", "on"}
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
package_root = Path(__file__).resolve().parent.parent
template_dir = resolved_base_dir / "templates"
static_dir = resolved_base_dir / "static"
if not template_dir.exists():
template_dir = package_root / "templates"
if not static_dir.exists():
static_dir = package_root / "static"
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"),
template_folder=str(template_dir),
static_folder=str(static_dir),
)
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.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=env_flag("VFM_SESSION_COOKIE_SECURE", is_production_mode()),
REMEMBER_COOKIE_HTTPONLY=True,
REMEMBER_COOKIE_SAMESITE="Lax",
MAX_CONTENT_LENGTH=int(os.getenv("VFM_MAX_CONTENT_LENGTH", str(1024 * 1024))),
)
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)
if env_flag("VFM_TRUST_PROXY"):
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
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)
@app.after_request
def apply_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Cache-Control"] = "no-store"
return response
if start_monitor:
monitor_engine = MonitorEngine(runtime_state, license_service)
app.extensions["monitor_engine"] = monitor_engine
monitor_engine.start()
atexit.register(monitor_engine.stop)
return app
+498 -358
View File
@@ -5,73 +5,75 @@ 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
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"},
}
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 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 __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")
@@ -80,303 +82,435 @@ class JsonStore:
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 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")
secret_path.write_text(secret, encoding="utf-8")
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 {})
if config.get("web_password") and not config.get("web_password_hash"):
config["web_password_hash"] = generate_password_hash(config["web_password"])
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)
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"
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")
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 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}
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
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=502,
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
)
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 __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)
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:
def install(self, serial: str) -> LicenseStatus:
cleaned = serial.strip()
if not cleaned:
raise ValueError("Serial vazio.")
self.license_path.write_text(cleaned, encoding="utf-8")
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({})
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),
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:
def get_monitor_status(self) -> MonitorStatus:
with self.lock:
self.config_store.save(self.config)
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 consume_restart(self) -> bool:
if self.restart_event.is_set():
self.restart_event.clear()
return True
return False
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_config(self, zabbix_server: str, hostname_zabbix: str, nodes: List[dict]) -> None:
def update_credentials(self, username: str, password: str) -> None:
normalized_username, normalized_password = validate_login_payload(username, password)
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["web_user"] = normalized_username
self.config["web_password_hash"] = generate_password_hash(normalized_password)
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 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)
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:
threading.Thread(target=self.run, daemon=True, name="monitor-engine").start()
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 = {}
time.sleep(RETRY_INTERVAL_SECONDS)
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()
@@ -390,105 +524,111 @@ class MonitorEngine:
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)
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
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):
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=502,
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))
)
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
return node_metrics, node_data, node_status
+184 -91
View File
@@ -1,22 +1,59 @@
from flask import current_app, flash, jsonify, redirect, render_template, request, url_for
import secrets
from flask import current_app, flash, jsonify, redirect, render_template, request, session, 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"]
from .core import (
LicenseService,
RuntimeState,
User,
discover_node,
get_status_info,
validate_login_payload,
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 get_csrf_token() -> str:
token = session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
session["csrf_token"] = token
return token
def validate_csrf() -> bool:
expected = session.get("csrf_token")
provided = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token")
return bool(expected and provided and secrets.compare_digest(expected, provided))
def register_routes(app):
@app.route("/healthz")
def healthz():
return jsonify({"status": "ok"})
@app.before_request
def enforce_csrf():
if request.method in {"POST", "PUT", "PATCH", "DELETE"} and request.endpoint != "healthz":
if not validate_csrf():
if request.path.startswith("/api/"):
return json_error("CSRF invalido.", 403)
flash("Sessao expirada. Tente novamente.")
return redirect(url_for("login"))
@app.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
@@ -27,15 +64,18 @@ def register_routes(app):
password = request.form.get("password", "")
if get_runtime_state().authenticate(username, password):
login_user(User(username))
session.permanent = True
session["csrf_token"] = secrets.token_urlsafe(32)
return redirect(url_for("index"))
flash("Usuario ou senha invalidos.")
return render_template("login.html")
return render_template("login.html", csrf_token=get_csrf_token())
@app.route("/")
@login_required
def index():
config, _, _, _ = get_runtime_state().snapshot()
license_status = get_license_service().verify()
monitor_status = get_runtime_state().get_monitor_status()
return render_template(
"index.html",
config=config,
@@ -43,108 +83,161 @@ def register_routes(app):
lic_msg=license_status.message,
hwid=license_status.hardware_id,
dias_restantes=license_status.days_remaining,
csrf_token=get_csrf_token(),
monitor_status=monitor_status,
)
@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/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"])
@login_required
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", "")))
status = get_license_service().install(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"])
return jsonify({"status": "success", "message": status.message, "days_remaining": status.days_remaining})
@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_rescan():
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("/api/system/status")
@login_required
def api_system_status():
config, device_map, realtime_data, node_statuses = get_runtime_state().snapshot()
license_status = get_license_service().verify()
monitor_status = get_runtime_state().get_monitor_status()
return jsonify(
{
"status": "success",
"license": {
"valid": license_status.valid,
"message": license_status.message,
"hardware_id": license_status.hardware_id,
"days_remaining": license_status.days_remaining,
},
"monitor": monitor_status.__dict__,
"counts": {
"nodes": len(config.get("nodes", [])),
"devices": len(device_map),
"realtime_points": len(realtime_data),
},
"nodes_status": node_statuses,
}
)
@app.route("/api/account", methods=["POST"])
@login_required
def api_account():
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return json_error("JSON invalido.")
username = str(payload.get("username", ""))
current_password = str(payload.get("current_password", ""))
new_password = str(payload.get("new_password", ""))
if not get_runtime_state().authenticate(current_user.get_id(), current_password):
return json_error("Senha atual invalida.", 403)
try:
node = validate_node_payload(payload)
validated_username, validated_password = validate_login_payload(username, new_password)
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})
get_runtime_state().update_credentials(validated_username, validated_password)
logout_user()
session.clear()
return jsonify({"status": "success"})
@app.route("/logout")
@login_required
def logout():
logout_user()
session.clear()
return redirect(url_for("login"))