Added distributed MQTT recovery backend and device governance

This commit is contained in:
XupaMisto 2026-05-17 20:48:11 +01:00
parent 269ea239f2
commit a028fc6497
12 changed files with 1369 additions and 199 deletions

View File

@ -3,7 +3,8 @@ declare(strict_types=1);
require __DIR__ . "/db.php";
$rows = $pdo->query("
SELECT uid, entradas, saidas, online, blocked,
SELECT uid, note, entradas, saidas, online, blocked,
current_broker, rssi, heap, uptime,
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
FROM contadores
ORDER BY online DESC, uid ASC

View File

@ -9,17 +9,27 @@ $LOG_LEVEL = LOG_DEBUG;
//$LOG_LEVEL = LOG_INFO;
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/mqtt-listener/recovery-error.log');
set_time_limit(0);
ignore_user_abort(true);
function logmsg(string $msg, int $level = LOG_INFO): void {
function logmsg(string $msg, int $level = LOG_INFO): void
{
global $LOG_LEVEL;
if ($level > $LOG_LEVEL) return;
if ($level > $LOG_LEVEL) {
return;
}
echo '[' . date('Y-m-d H:i:s') . "] $msg\n";
if (function_exists('flush')) @flush();
if (function_exists('flush')) {
@flush();
}
}
require __DIR__ . '/../vendor/autoload.php';
@ -27,28 +37,53 @@ require __DIR__ . '/../vendor/autoload.php';
use PhpMqtt\Client\MqttClient;
use PhpMqtt\Client\ConnectionSettings;
// =====================================================
// DB
// =====================================================
$db = new PDO(
"mysql:host=localhost;dbname=cagalhao;charset=utf8",
"master",
"master",
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]
);
// RECOVERY BROKER
// =====================================================
// RECOVERY MQTT
// =====================================================
$recoveryHost = 'mqtt-recovery.xupas.mywire.org';
$recoveryPort = 8884;
$recoveryUser = 'recovery';
$recoveryPass = 'recovery123';
// MAIN BROKER TEST
// =====================================================
// MAIN MQTT
// =====================================================
$mainHost = 'mqtt.xupas.mywire.org';
$mainPort = 8883;
// Cert principal a enviar aos ESPs
$certPath = "/etc/mosquitto/certs/server.crt";
// =====================================================
// CERT
// =====================================================
$certPath = '/etc/mosquitto/certs/chain.pem'; // cert Let's Encrypt do broker principal
$certUrl = 'https://mqtt.xupas.mywire.org/cert.pem';
// =====================================================
// GLOBAL SECRET
// =====================================================
$authSecret = 'K82A91F0';
// =====================================================
// MQTT SETTINGS
// =====================================================
// MQTT recovery settings
$settings = (new ConnectionSettings())
->setUsername($recoveryUser)
->setPassword($recoveryPass)
@ -59,22 +94,38 @@ $settings = (new ConnectionSettings())
->setSocketTimeout(1)
->setReconnectAutomatically(true);
// =====================================================
// MQTT CLIENT
// =====================================================
$mqtt = new MqttClient(
$recoveryHost,
$recoveryPort,
'listener-recovery-' . getmypid()
);
function main_broker_online(string $host, int $port): bool {
// =====================================================
// MAIN BROKER CHECK
// =====================================================
function main_broker_online(string $host, int $port): bool
{
$fp = @fsockopen($host, $port, $errno, $errstr, 2);
if ($fp) {
fclose($fp);
return true;
}
return false;
}
function send_switch_primary(MqttClient $mqtt, PDO $db): void {
// =====================================================
// SEND SWITCH PRIMARY
// =====================================================
function send_switch_primary(MqttClient $mqtt, PDO $db): void
{
$stmt = $db->query("
SELECT uid
FROM contadores
@ -82,11 +133,14 @@ function send_switch_primary(MqttClient $mqtt, PDO $db): void {
");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$uid = $row['uid'];
$mqtt->publish(
"esp/$uid/cmd",
json_encode(['cmd' => 'switch_primary']),
json_encode([
'cmd' => 'SWITCH_PRIMARY'
]),
1
);
@ -94,7 +148,17 @@ function send_switch_primary(MqttClient $mqtt, PDO $db): void {
}
}
function send_cert_update(MqttClient $mqtt, string $uid, string $certPath): void {
// =====================================================
// SEND CERT
// =====================================================
function send_cert_chunks(
MqttClient $mqtt,
string $uid,
string $certPath,
int $linesPerChunk = 16
): void {
if (!file_exists($certPath)) {
logmsg("Cert não encontrado: $certPath", LOG_ERROR);
return;
@ -102,47 +166,317 @@ function send_cert_update(MqttClient $mqtt, string $uid, string $certPath): void
$cert = file_get_contents($certPath);
// validar com openssl antes de enviar
$parsed = openssl_x509_read($cert);
if ($parsed === false) {
logmsg("Cert inválido (openssl): $certPath", LOG_ERROR);
return;
}
$lines = explode("\n", rtrim($cert));
$chunks = array_chunk($lines, $linesPerChunk);
$total = count($chunks);
logmsg("CERT_BEGIN $uid total=$total chunks");
$mqtt->publish(
"esp/$uid/cmd",
json_encode([
'cmd' => 'UPDATE_CERT',
'pem' => $cert
]),
json_encode(['cmd' => 'CERT_BEGIN', 'total' => $total]),
1
);
logmsg("UPDATE_CERT enviado para $uid");
foreach ($chunks as $seq => $chunk) {
$data = implode("\n", $chunk) . "\n";
$mqtt->publish(
"esp/$uid/cmd",
json_encode(['cmd' => 'CERT_CHUNK', 'seq' => $seq, 'data' => $data]),
1
);
logmsg("CERT_CHUNK $uid seq=$seq");
usleep(100000); // 100ms entre chunks
}
$mqtt->publish(
"esp/$uid/cmd",
json_encode(['cmd' => 'CERT_COMMIT']),
1
);
logmsg("CERT_COMMIT enviado para $uid");
}
// =====================================================
// SUBSCRIPTIONS
// =====================================================
$subscribe = function () use (
$mqtt,
$db,
$certPath,
$certUrl,
$mainHost,
$mainPort
$mainPort,
$authSecret
) {
// =================================================
// AUTH
// =================================================
$mqtt->subscribe(
'esp/+/status',
'esp/+/auth',
function (string $topic, string $msg)
use ($db, $mqtt, $mainHost, $mainPort) {
use ($db, $mqtt, $authSecret)
{
$uid = explode('/', $topic)[1] ?? null;
if (!$uid) return;
if (!$uid) {
return;
}
logmsg("AUTH RX $uid => $msg");
$json = json_decode($msg, true);
if (!$json) {
logmsg(
"JSON AUTH inválido",
LOG_ERROR
);
return;
}
// =============================================
// HELLO
// =============================================
if (($json['cmd'] ?? '') === 'HELLO') {
$nonce =
strtoupper(
bin2hex(random_bytes(16))
);
$stmt = $db->prepare("
INSERT INTO contadores
(uid, entradas, saidas, entradas_total, saidas_total, last_seen, online)
(uid, auth_nonce, last_seen, online, blocked)
VALUES
(:uid,0,0,0,0,NOW(),1)
(:uid, :nonce, NOW(), 1, 1)
ON DUPLICATE KEY UPDATE
auth_nonce=:nonce,
last_seen=NOW(),
online=1
");
/* novos ESPs entram com blocked=1 (pendente aprovação)
ESPs existentes mantêm o blocked actual */
$stmt->execute([
'uid' => $uid,
'nonce' => $nonce
]);
$mqtt->publish(
"esp/$uid/cmd",
json_encode([
'cmd' => 'AUTH_CHALLENGE',
'nonce' => $nonce
]),
1
);
logmsg(
"AUTH_CHALLENGE enviado para $uid"
);
return;
}
// =============================================
// AUTH RESPONSE
// =============================================
if (($json['cmd'] ?? '') === 'AUTH_RESPONSE') {
$hash =
strtoupper(
$json['hash'] ?? ''
);
if (!$hash) {
logmsg(
"AUTH sem hash",
LOG_ERROR
);
return;
}
$stmt = $db->prepare("
SELECT auth_nonce, blocked
FROM contadores
WHERE uid=:uid
LIMIT 1
");
$stmt->execute([
'uid' => $uid
]);
$row =
$stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
logmsg(
"UID desconhecido: $uid",
LOG_ERROR
);
return;
}
$nonce =
$row['auth_nonce'] ?? '';
if (!$nonce) {
logmsg(
"Nonce vazio para $uid",
LOG_ERROR
);
return;
}
$expected =
strtoupper(
hash_hmac(
'sha256',
$nonce,
$authSecret
)
);
if (hash_equals($expected, $hash)) {
// verificar se está aprovado
if ((int)($row['blocked'] ?? 1) === 1) {
logmsg("AUTH PENDENTE/BLOQUEADO $uid — aguarda aprovação", LOG_ERROR);
$mqtt->publish(
"esp/$uid/cmd",
json_encode(['cmd' => 'AUTH_FAIL']),
1
);
return;
}
logmsg("AUTH OK $uid");
$stmt = $db->prepare("
UPDATE contadores
SET
auth_nonce=NULL,
auth_ok=1,
online=1,
last_seen=NOW()
WHERE uid=:uid
");
$stmt->execute([
'uid' => $uid
]);
$mqtt->publish(
"esp/$uid/cmd",
json_encode([
'cmd' => 'AUTH_OK'
]),
1
);
} else {
logmsg(
"AUTH FAIL $uid",
LOG_ERROR
);
$mqtt->publish(
"esp/$uid/cmd",
json_encode([
'cmd' => 'AUTH_FAIL'
]),
1
);
}
return;
}
}, 1);
// =================================================
// STATUS
// =================================================
$mqtt->subscribe(
'esp/+/status',
function (
string $topic,
string $msg
) use (
$db,
$mqtt,
$mainHost,
$mainPort
) {
$uid = explode('/', $topic)[1] ?? null;
if (!$uid) {
return;
}
$stmt = $db->prepare("
INSERT INTO contadores
(
uid,
entradas,
saidas,
entradas_total,
saidas_total,
last_seen,
online
)
VALUES
(
:uid,
0,
0,
0,
0,
NOW(),
1
)
ON DUPLICATE KEY UPDATE
last_seen=NOW(),
online=1
");
$stmt->execute(['uid' => $uid]);
$stmt->execute([
'uid' => $uid
]);
logmsg("RECOVERY STATUS $uid => $msg");
logmsg(
"RECOVERY STATUS $uid => $msg"
);
static $lastCheck = 0;
@ -152,15 +486,27 @@ $subscribe = function () use (
logmsg("CHECK broker");
if (main_broker_online($mainHost, $mainPort)) {
if (
main_broker_online(
$mainHost,
$mainPort
)
) {
logmsg("MAIN broker online");
logmsg(
"MAIN broker online"
);
send_switch_primary($mqtt, $db);
send_switch_primary(
$mqtt,
$db
);
} else {
logmsg("MAIN broker offline");
logmsg(
"MAIN broker offline"
);
}
}
@ -168,61 +514,140 @@ $subscribe = function () use (
0
);
// =================================================
// EVENT
// =================================================
$mqtt->subscribe('esp/+/event', function (string $topic, string $msg) use ($db) {
$uid = explode('/', $topic)[1] ?? null;
if (!$uid) return;
$mqtt->subscribe(
'esp/+/event',
function (
string $topic,
string $msg
) {
logmsg("RECOVERY EVENT $uid => $msg");
}, 0);
$uid =
explode('/', $topic)[1] ?? null;
$mqtt->subscribe('esp/+/cert_request', function (string $topic, string $msg) use ($mqtt, $certPath) {
$uid = explode('/', $topic)[1] ?? null;
if (!$uid) return;
if (!$uid) {
return;
}
logmsg("CERT_REQUEST de $uid");
send_cert_update($mqtt, $uid, $certPath);
}, 0);
logmsg(
"RECOVERY EVENT $uid => $msg"
);
},
0
);
// =================================================
// CERT REQUEST
// =================================================
$mqtt->subscribe(
'esp/+/cert_request',
function (
string $topic,
string $msg
) use (
$mqtt,
$certPath,
$certUrl
) {
$uid =
explode('/', $topic)[1] ?? null;
if (!$uid) {
return;
}
logmsg("CERT_REQUEST de $uid → chunks");
send_cert_chunks($mqtt, $uid, $certPath);
},
1
);
};
// =====================================================
// MAIN LOOP
// =====================================================
while (true) {
try {
logmsg("Ligar recovery $recoveryHost:$recoveryPort");
logmsg(
"Ligar recovery $recoveryHost:$recoveryPort"
);
$mqtt->connect($settings, false);
$subscribe();
logmsg("RECOVERY ligado e subscrito");
logmsg(
"RECOVERY ligado e subscrito"
);
$nextMainCheck = time() + 10;
while ($mqtt->isConnected()) {
$mqtt->loop(false);
logmsg("tick");
if (time() >= $nextMainCheck) {
$nextMainCheck = time() + 15;
logmsg("LOOP recovery", LOG_DEBUG);
logmsg(
"LOOP recovery",
LOG_DEBUG
);
if (main_broker_online($mainHost, $mainPort)) {
if (
main_broker_online(
$mainHost,
$mainPort
)
) {
logmsg("MAIN broker online");
logmsg(
"MAIN broker online"
);
logmsg(
"Mandar switch_primary"
);
send_switch_primary(
$mqtt,
$db
);
logmsg("MAIN broker online. Mandar switch_primary.");
send_switch_primary($mqtt, $db);
} else {
logmsg("MAIN broker ainda offline", LOG_DEBUG);
logmsg(
"MAIN broker ainda offline",
LOG_DEBUG
);
}
}
usleep(100000);
}
$mqtt->disconnect();
} catch (Throwable $e) {
logmsg("ERRO recovery: " . $e->getMessage(), LOG_ERROR);
logmsg(
"ERRO recovery: " .
$e->getMessage(),
LOG_ERROR
);
sleep(5);
}
}

View File

@ -92,6 +92,9 @@ if (isset($_GET['vpn'])) {
<div class="col-12">
<a href="../console.php" class="btn btn-outline-light w-100">📊 Estado dos Dispositivos</a>
</div>
<div class="col-12">
<a href="../devices.php" class="btn btn-outline-warning w-100">🔌 Gerir Dispositivos</a>
</div>
<div class="col-12">
<a href="../comandos.php" class="btn btn-outline-light w-100">🛠️ Enviar Comando</a>
</div>

View File

@ -63,6 +63,8 @@ $mqttHost = 'mqtt.xupas.mywire.org';
$mqttUser = 'xupa';
$mqttPass = 'xupa';
$deviceSecret = 'K82A91F0';
$bootstrapSecret = "XUPA_TEMP_SECRET_2026";
$certPath = "/etc/mosquitto/certs/server.crt";
@ -98,24 +100,70 @@ $mqttBootstrap = new MqttClient($mqttHost, 1883, 'listener-bootstrap');
/* =========================
* SUBSCRIPTIONS TLS
* ========================= */
$subscribeTls = function () use ($mqttTls, $db) {
$subscribeTls = function () use ($mqttTls, $db, $deviceSecret) {
// =========================
// STATUS ESP
// =========================
$mqttTls->subscribe('esp/+/status', function (string $topic, string $msg) use ($db) {
$mqttTls->subscribe('esp/+/status', function (string $topic, string $msg)
use ($db, $mqttTls, $deviceSecret)
{
$uid = explode('/', $topic)[1] ?? null;
if (!$uid) return;
$stmt = $db->prepare("
INSERT INTO contadores (uid, entradas, saidas, entradas_total, saidas_total, last_seen, online)
VALUES (:uid,0,0,0,0,NOW(),1)
ON DUPLICATE KEY UPDATE last_seen=NOW(), online=1
");
$stmt->execute(['uid' => $uid]);
$json = json_decode($msg, true);
$uptime = isset($json['uptime']) ? (int)$json['uptime'] : null;
$heap = isset($json['heap']) ? (int)$json['heap'] : null;
$rssi = isset($json['rssi']) ? (int)$json['rssi'] : null;
$broker = isset($json['broker']) ? (string)$json['broker'] : null;
$hmac = isset($json['hmac']) ? strtoupper((string)$json['hmac']) : null;
logmsg("STATUS $uid");
// Validar HMAC — apenas aceitar heartbeats assinados
if ($hmac === null || $uptime === null) {
logmsg("STATUS sem HMAC/uptime $uid — ignorado", LOG_ERROR);
return;
}
$expected = strtoupper(hash_hmac('sha256', $uid . '|' . $uptime, $deviceSecret));
if (!hash_equals($expected, $hmac)) {
logmsg("STATUS HMAC INVÁLIDO $uid — ignorado", LOG_ERROR);
return;
}
// HMAC OK → actualizar DB
$stmt = $db->prepare("
INSERT INTO contadores
(uid, entradas, saidas, entradas_total, saidas_total, last_seen, online,
uptime, heap, rssi, current_broker)
VALUES
(:uid, 0, 0, 0, 0, NOW(), 1,
:uptime, :heap, :rssi, :broker)
ON DUPLICATE KEY UPDATE
last_seen=NOW(),
online=1,
uptime=COALESCE(:uptime2, uptime),
heap=COALESCE(:heap2, heap),
rssi=COALESCE(:rssi2, rssi),
current_broker=COALESCE(:broker2, current_broker)
");
$stmt->execute([
'uid' => $uid,
'uptime' => $uptime,
'heap' => $heap,
'rssi' => $rssi,
'broker' => $broker,
'uptime2' => $uptime,
'heap2' => $heap,
'rssi2' => $rssi,
'broker2' => $broker,
]);
// Responder com HEARTBEAT_OK → ESP arranca serviços (sem retain)
$mqttTls->publish("esp/$uid/cmd",
json_encode(['cmd' => 'HEARTBEAT_OK']), 1, false);
logmsg("STATUS OK $uid uptime=$uptime heap=$heap rssi=$rssi broker=$broker → HEARTBEAT_OK");
}, 0);
@ -217,11 +265,28 @@ while (true) {
$subscribeBootstrap();
logmsg("Bootstrap ligado");
$nextOfflineCheck = time() + 15;
while ($mqttTls->isConnected() && $mqttBootstrap->isConnected()) {
$mqttTls->loop(true, false);
$mqttBootstrap->loop(true, false);
if (time() >= $nextOfflineCheck) {
$nextOfflineCheck = time() + 15;
$affected = $db->exec("
UPDATE contadores
SET online=0
WHERE online=1
AND TIMESTAMPDIFF(SECOND, last_seen, NOW()) > 35
");
if ($affected > 0) {
logmsg("OFFLINE sweep: $affected dispositivo(s) marcado(s) offline");
}
}
usleep(100000);
}

View File

@ -3,6 +3,6 @@ if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user_id'])) {
header("Location: login.php");
header("Location: /api/login.php");
exit;
}

View File

@ -27,7 +27,7 @@ mqtt_publish($topic, json_encode($msg, JSON_UNESCAPED_SLASHES));
// ----------------------- BD -------------------------
if ($cmd === 'BLOCK') {
$stmt = $db->prepare("UPDATE contadores SET blocked = ? WHERE uid = ?");
$stmt = $pdo->prepare("UPDATE contadores SET blocked = ? WHERE uid = ?");
$stmt->execute([intval($value), $uid]);
}

View File

@ -33,7 +33,7 @@ try {
$_SESSION['username'] = $user['username'];
// Redireciona para o menu principal
header("Location: menu.php");
header("Location: /api/menu.php");
exit;
} catch (Exception $e) {

View File

@ -22,7 +22,7 @@ try {
}
// Buscar lista de devices
$devices = $pdo->query("SELECT uid FROM devices ORDER BY uid ASC")->fetchAll();
$devices = $pdo->query("SELECT uid FROM contadores ORDER BY uid ASC")->fetchAll();
} catch (Exception $e) {
die("Erro: " . $e->getMessage());

View File

@ -1,7 +1,7 @@
<?php
return [
'host' => '192.168.10.200', // depois trocas para 'estica.bot.nu'
'port' => 1883, // 8883 se fores usar TLS mais tarde
'host' => 'mqtt.xupas.mywire.org',
'port' => 8883,
'clientId' => 'php-web-' . bin2hex(random_bytes(3)),
'username' => 'xupa',
'password' => 'xupa',

View File

@ -1,199 +1,482 @@
<?php
declare(strict_types=1);
require_once __DIR__ . "/api/protecao.php";
require __DIR__ . "/api/db.php";
// ---------- SORT ----------
$sort = $_GET['sort'] ?? 'state';
$orderSql = ($sort === 'uid')
? "ORDER BY uid ASC"
: "ORDER BY online DESC, uid ASC";
// =====================================================
// GUARDAR NOTA
// =====================================================
// ---------- OFFLINE AUTOMÁTICO (20s) ----------
$pdo->query("
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_note'])) {
$uid = $_POST['uid'] ?? '';
$note = trim($_POST['note'] ?? '');
$stmt = $pdo->prepare("
UPDATE contadores
SET online = 0
WHERE TIMESTAMPDIFF(SECOND, last_seen, NOW()) > 20
");
SET note = ?
WHERE uid = ?
");
$stmt->execute([$note, $uid]);
// ---------- BLOQUEIO ----------
if (isset($_GET['toggle'])) {
$uid = $_GET['toggle'];
$pdo->prepare("UPDATE contadores SET blocked = 1 - blocked WHERE uid = ?")
->execute([$uid]);
header("Location: console.php");
exit;
}
// ---------- PRIMEIRA QUERY (para totals) ----------
// =====================================================
// REMOVER
// =====================================================
if (isset($_GET['remove'])) {
$uid = $_GET['remove'];
$pdo->prepare("
DELETE FROM contadores
WHERE uid = ?
")->execute([$uid]);
header("Location: console.php");
exit;
}
// =====================================================
// BLOQUEAR / DESBLOQUEAR
// =====================================================
if (isset($_GET['toggle'])) {
$uid = $_GET['toggle'];
$pdo->prepare("
UPDATE contadores
SET blocked = 1 - blocked
WHERE uid = ?
")->execute([$uid]);
header("Location: console.php");
exit;
}
// =====================================================
// SORT
// =====================================================
$sort = $_GET['sort'] ?? 'state';
$orderSql = ($sort === 'uid')
? "ORDER BY uid ASC"
: "ORDER BY online DESC, uid ASC";
// =====================================================
// QUERY
// =====================================================
$sql = "
SELECT uid, entradas, saidas, online, blocked,
SELECT
uid,
note,
entradas,
saidas,
online,
blocked,
current_broker,
rssi,
heap,
uptime,
last_seen,
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
FROM contadores
$orderSql
";
$stmt = $pdo->query($sql);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Totais
// =====================================================
// TOTAIS
// =====================================================
$sumEntradas = 0;
$sumSaidas = 0;
$onlineCount = 0;
$offlineCount = 0;
foreach ($rows as $r) {
$sumEntradas += (int)$r['entradas'];
$sumSaidas += (int)$r['saidas'];
if ($r['online'] == 1) $onlineCount++;
else $offlineCount++;
if ((int)$r['online'] === 1) {
$onlineCount++;
} else {
$offlineCount++;
}
}
?>
<!doctype html>
<html lang="pt" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<title>Painel de Contadores</title>
<title>MQTT Swarm Console</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<style>
body {
background-color: #0f0f0f;
background: #0b0b0b;
color: #e0e0e0;
font-family: Arial, sans-serif;
}
.card {
background-color: #1a1a1a;
background: #151515;
border: 1px solid #333;
}
.table {
color: #ddd;
}
.table-striped > tbody > tr:nth-of-type(odd) {
background-color: #1c1c1c;
background-color: #171717;
}
.table-striped > tbody > tr:nth-of-type(even) {
background-color: #151515;
background-color: #101010;
}
.table thead {
background-color: #222;
.table-hover tbody tr:hover {
background-color: #202020;
}
h1 {
color: #f0f0f0;
.note-input {
min-width: 220px;
background: #111;
border: 1px solid #333;
color: #fff;
}
a {
color: #58a6ff;
.note-input:focus {
background: #181818;
color: #fff;
border-color: #58a6ff;
box-shadow: none;
}
a:hover {
color: #8cc5ff;
.pulse-cell {
animation: pulse 1s infinite;
}
.btn-danger {
background-color: #a32424;
border-color: #6b0000;
@keyframes pulse {
0% {
background-color: rgba(0,255,0,0.03);
}
50% {
background-color: rgba(0,255,0,0.16);
}
100% {
background-color: rgba(0,255,0,0.03);
}
}
.btn-success {
background-color: #1f6f33;
border-color: #0f4d1f;
.header-title {
color: #00ffcc;
text-shadow: 0 0 8px rgba(0,255,180,0.4);
}
.small-info {
font-size: 12px;
color: #999;
}
</style>
</head>
<body>
<div class="container py-4">
<div class="container-fluid py-4">
<h1 class="mb-4">Painel de Contadores</h1>
<h1 class="mb-4 header-title">
🌋 MQTT SWARM CONSOLE
</h1>
<!-- RESUMO -->
<div class="row mb-4 text-center">
<div class="col-md-3">
<div class="p-3 card rounded">
<h4><?= $onlineCount ?></h4>
<div class="text-success fw-bold">Online</div>
<h4 id="total_online"><?= $onlineCount ?></h4>
<div class="text-success fw-bold">
🟢 ONLINE
</div>
</div>
</div>
<div class="col-md-3">
<div class="p-3 card rounded">
<h4><?= $offlineCount ?></h4>
<div class="text-danger fw-bold">Offline</div>
<h4 id="total_offline"><?= $offlineCount ?></h4>
<div class="text-danger fw-bold">
🔴 OFFLINE
</div>
</div>
</div>
<div class="col-md-3">
<div class="p-3 card rounded">
<h4><?= $sumEntradas ?></h4>
<div class="fw-bold">Entradas</div>
<h4 id="total_entradas"><?= $sumEntradas ?></h4>
<div class="fw-bold">
💰 ENTRADAS
</div>
</div>
</div>
<div class="col-md-3">
<div class="p-3 card rounded">
<h4><?= $sumSaidas ?></h4>
<div class="fw-bold">Saídas</div>
<h4 id="total_saidas"><?= $sumSaidas ?></h4>
<div class="fw-bold">
💸 SAÍDAS
</div>
</div>
</div>
</div>
<!-- TABELA -->
<div class="card shadow-sm">
<div class="card-body">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th><a href="?sort=uid">UID</a></th>
<th>Entradas</th>
<th>Saídas</th>
<th><a href="?sort=state">Estado</a></th>
<th>Último Sinal</th>
<th>Ações</th>
<th>UID</th>
<th>NOTA</th>
<th>ENTRADAS</th>
<th>SAÍDAS</th>
<th>ESTADO</th>
<th>BROKER</th>
<th>RSSI</th>
<th>HEAP</th>
<th>UPTIME</th>
<th>ÚLTIMO</th>
<th>AÇÕES</th>
</tr>
</thead>
<tbody id="tabela_corpo">
<?php foreach ($rows as $r): ?>
<?php
$online = (int)$r['online'] === 1;
$blocked = (int)$r['blocked'] === 1;
if ($blocked) {
$state = '<span class="badge bg-danger">Bloqueado</span>';
$state =
'<span class="badge bg-danger">
BLOQUEADO
</span>';
} elseif ($online) {
$state = '<span class="badge bg-success">Online</span>';
$state =
'<span class="badge bg-success">
ONLINE
</span>';
} else {
$state = '<span class="badge bg-secondary">Offline</span>';
$state =
'<span class="badge bg-secondary">
OFFLINE
</span>';
}
if (($r['current_broker'] ?? '') === 'recovery') {
$broker =
'<span class="badge bg-warning text-dark">
RECOVERY
</span>';
} else {
$broker =
'<span class="badge bg-primary">
PRIMARY
</span>';
}
$ago = (int)$r['ago'];
$lastSeen = ($ago < 60)
? "{$ago}s"
: round($ago / 60) . "m";
$pulseClass =
($ago <= 2 && $online)
? "pulse-cell"
: "";
?>
<tr>
<td class="fw-bold"><?= htmlspecialchars($r['uid']) ?></td>
<td><?= $r['entradas'] ?></td>
<td><?= $r['saidas'] ?></td>
<td><?= $state ?></td>
<td><?= $lastSeen ?></td>
<tr class="<?= $pulseClass ?>">
<td class="fw-bold">
<?= htmlspecialchars($r['uid']) ?>
</td>
<td>
<form method="post">
<input type="hidden"
name="uid"
value="<?= htmlspecialchars($r['uid']) ?>">
<div class="d-flex gap-2">
<input type="text"
name="note"
value="<?= htmlspecialchars($r['note'] ?? '') ?>"
class="form-control form-control-sm note-input"
placeholder="nota...">
<button type="submit"
name="save_note"
class="btn btn-sm btn-primary">
Save
</button>
</div>
</form>
</td>
<td><?= (int)$r['entradas'] ?></td>
<td><?= (int)$r['saidas'] ?></td>
<td><?= $state ?></td>
<td><?= $broker ?></td>
<td>
<?= isset($r['rssi'])
? $r['rssi'] . " dBm"
: "-" ?>
</td>
<td>
<?= isset($r['heap'])
? number_format((int)$r['heap'])
: "-" ?>
</td>
<td>
<?php
if (isset($r['uptime'])) {
$up = (int)$r['uptime'];
echo gmdate("H:i:s", $up);
} else {
echo "-";
}
?>
</td>
<td><?= $lastSeen ?></td>
<td>
<div class="d-flex gap-2">
<a href="?toggle=<?= urlencode($r['uid']) ?>"
class="btn btn-sm <?= $blocked ? 'btn-success' : 'btn-danger' ?>">
<?= $blocked ? 'Desbloquear' : 'Bloquear' ?>
<?= $blocked
? 'Desbloquear'
: 'Bloquear' ?>
</a>
<a href="?remove=<?= urlencode($r['uid']) ?>"
class="btn btn-sm btn-warning"
onclick="return confirm('Remover device?')">
Remover
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- ------------ AUTO-REFRESH AJAX ---------------- -->
<script>
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function refreshTable() {
try {
const res = await fetch('/api/contadores_json.php?x=' + Date.now());
const res =
await fetch(
'/api/contadores_json.php?x='
+ Date.now()
);
const data = await res.json();
@ -201,62 +484,173 @@ async function refreshTable() {
for (const r of data.rows) {
// Estado
// Estado baseado APENAS no valor online vindo da DB
let state = "";
if (r.blocked == 1) {
state = '<span class="badge bg-danger">Bloqueado</span>';
}
else if (r.online == 1) {
state = '<span class="badge bg-success">Online</span>';
}
else {
state = '<span class="badge bg-secondary">Offline</span>';
state =
'<span class="badge bg-danger">BLOQUEADO</span>';
} else if (r.online == 1) {
state =
'<span class="badge bg-success">ONLINE</span>';
} else {
state =
'<span class="badge bg-secondary">OFFLINE</span>';
}
// Tempo
let lastSeen = (r.ago < 60) ? `${r.ago}s` : `${Math.round(r.ago/60)}m`;
let broker = "";
// Animação pulse quando r.ago <= 2
let pulseClass = (r.ago <= 2 && r.online == 1) ? "pulse-cell" : "";
if (r.current_broker === 'recovery') {
broker =
'<span class="badge bg-warning text-dark">RECOVERY</span>';
} else {
broker =
'<span class="badge bg-primary">PRIMARY</span>';
}
let lastSeen =
(r.ago < 60)
? `${r.ago}s`
: `${Math.round(r.ago / 60)}m`;
let pulseClass =
(r.ago <= 2 && r.online == 1)
? "pulse-cell"
: "";
let uptime = "-";
if (r.uptime && !isNaN(r.uptime)) {
let sec = parseInt(r.uptime);
let h =
String(Math.floor(sec / 3600))
.padStart(2, '0');
let m =
String(Math.floor((sec % 3600) / 60))
.padStart(2, '0');
let s =
String(sec % 60)
.padStart(2, '0');
uptime = `${h}:${m}:${s}`;
}
html += `
<tr class="${pulseClass}">
<td class="fw-bold">${r.uid}</td>
<td>${r.entradas}</td>
<td>${r.saidas}</td>
<td>${state}</td>
<td>${lastSeen}</td>
<td>
<a href="?toggle=${r.uid}"
class="btn btn-sm ${r.blocked ? 'btn-success' : 'btn-danger'}">
${r.blocked ? 'Desbloquear' : 'Bloquear'}
</a>
<tr class="${esc(pulseClass)}">
<td class="fw-bold">
${esc(r.uid)}
</td>
<td>
<form method="post">
<input type="hidden"
name="uid"
value="${esc(r.uid)}">
<div class="d-flex gap-2">
<input type="text"
name="note"
value="${esc(r.note)}"
class="form-control form-control-sm note-input"
placeholder="nota...">
<button type="submit"
name="save_note"
class="btn btn-sm btn-primary">
Save
</button>
</div>
</form>
</td>
<td>${esc(r.entradas)}</td>
<td>${esc(r.saidas)}</td>
<td>${state}</td>
<td>${broker}</td>
<td>${r.rssi != null ? esc(r.rssi) + ' dBm' : '-'}</td>
<td>${r.heap != null ? Number(r.heap).toLocaleString() : '-'}</td>
<td>${uptime}</td>
<td>${esc(lastSeen)}</td>
<td>
<div class="d-flex gap-2">
<a href="?toggle=${esc(r.uid)}"
class="btn btn-sm ${r.blocked ? 'btn-success' : 'btn-danger'}">
${r.blocked ? 'Desbloquear' : 'Bloquear'}
</a>
<a href="?remove=${esc(r.uid)}"
class="btn btn-sm btn-warning"
onclick="return confirm('Remover device?')">
Remover
</a>
</div>
</td>
</tr>
`;
}
document.getElementById("tabela_corpo").innerHTML = html;
// Atualizar totais
document.getElementById("total_online").innerText = data.online;
document.getElementById("total_offline").innerText = data.offline;
document.getElementById("total_entradas").innerText = data.entradas;
document.getElementById("total_saidas").innerText = data.saidas;
document.getElementById("total_online").innerText =
data.online;
document.getElementById("total_offline").innerText =
data.offline;
document.getElementById("total_entradas").innerText =
data.entradas;
document.getElementById("total_saidas").innerText =
data.saidas;
} catch (err) {
console.error("Erro AJAX:", err);
}
}
setInterval(refreshTable, 2000);
refreshTable();
</script>
refreshTable();
</script>
</body>
</html>

266
devices.php Normal file
View File

@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
require_once __DIR__ . "/api/protecao.php";
require __DIR__ . "/api/db.php";
require_once __DIR__ . "/vendor/autoload.php";
use PhpMqtt\Client\MqttClient;
use PhpMqtt\Client\ConnectionSettings;
$mqttCfg = require __DIR__ . "/config.php";
function mqtt_publish(array $cfg, string $topic, array $payload): void
{
try {
$settings = (new ConnectionSettings())
->setUsername($cfg['username'])
->setPassword($cfg['password'])
->setUseTls(true)
->setTlsCertificateAuthorityFile('/etc/mosquitto/certs/chain.pem')
->setConnectTimeout(3);
$client = new MqttClient(
$cfg['host'], $cfg['port'],
'web-devices-' . getmypid()
);
$client->connect($settings, true);
$client->publish($topic, json_encode($payload), 1);
$client->disconnect();
} catch (Throwable $e) {
error_log("MQTT publish error: " . $e->getMessage());
}
}
// =====================================================
// APROVAR — desbloqueia e manda REJOIN via MQTT
// =====================================================
if (isset($_GET['approve'])) {
$uid = $_GET['approve'];
$pdo->prepare("
UPDATE contadores
SET blocked=0
WHERE uid=?
")->execute([$uid]);
// REJOIN para o ESP fazer auth imediatamente
mqtt_publish($mqttCfg, "esp/$uid/cmd", ['cmd' => 'REJOIN']);
header("Location: devices.php");
exit;
}
// =====================================================
// REJEITAR / REMOVER
// =====================================================
if (isset($_GET['reject'])) {
$uid = $_GET['reject'];
$pdo->prepare("
DELETE FROM contadores
WHERE uid=?
")->execute([$uid]);
header("Location: devices.php");
exit;
}
// =====================================================
// BLOQUEAR / DESBLOQUEAR
// =====================================================
if (isset($_GET['toggle'])) {
$uid = $_GET['toggle'];
$pdo->prepare("
UPDATE contadores
SET blocked = 1 - blocked
WHERE uid=?
")->execute([$uid]);
header("Location: devices.php");
exit;
}
// =====================================================
// DESPROMOVER — volta a pendente
// =====================================================
if (isset($_GET['demote'])) {
$uid = $_GET['demote'];
$pdo->prepare("
UPDATE contadores
SET blocked=1, auth_ok=0
WHERE uid=?
")->execute([$uid]);
header("Location: devices.php");
exit;
}
// =====================================================
// QUERY
// =====================================================
$pending = $pdo->query("
SELECT uid, note, last_seen, blocked, auth_ok,
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
FROM contadores
WHERE blocked=1 AND auth_ok=0
ORDER BY last_seen DESC
")->fetchAll();
$approved = $pdo->query("
SELECT uid, note, last_seen, online, blocked, auth_ok, current_broker,
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
FROM contadores
WHERE blocked=0 OR auth_ok=1
ORDER BY online DESC, last_seen DESC
")->fetchAll();
?>
<!doctype html>
<html lang="pt" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<title>Dispositivos</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<style>
body { background:#0b0b0b; color:#e0e0e0; font-family:Arial,sans-serif; }
.card { background:#151515; border:1px solid #333; }
.header-title { color:#00ffcc; text-shadow:0 0 8px rgba(0,255,180,0.4); }
</style>
</head>
<body>
<div class="container-fluid py-4">
<h1 class="mb-4 header-title">🔌 Gestão de Dispositivos</h1>
<!-- PENDENTES -->
<h4 class="text-warning mb-3"> Pendentes de Aprovação (<?= count($pending) ?>)</h4>
<?php if (empty($pending)): ?>
<div class="alert alert-secondary mb-4">Nenhum dispositivo pendente.</div>
<?php else: ?>
<div class="card shadow-sm mb-5">
<div class="card-body">
<table class="table table-dark table-hover align-middle mb-0">
<thead>
<tr>
<th>UID</th>
<th>NOTA</th>
<th>ÚLTIMO HELLO</th>
<th>AÇÕES</th>
</tr>
</thead>
<tbody>
<?php foreach ($pending as $r): ?>
<?php
$ago = (int)$r['ago'];
$lastSeen = $ago < 60 ? "{$ago}s" : round($ago/60) . "m";
?>
<tr>
<td class="fw-bold text-warning"><?= htmlspecialchars($r['uid']) ?></td>
<td><?= htmlspecialchars($r['note'] ?? '') ?></td>
<td><?= $lastSeen ?></td>
<td>
<div class="d-flex gap-2">
<a href="?approve=<?= urlencode($r['uid']) ?>"
class="btn btn-sm btn-success"
onclick="return confirm('Aprovar <?= htmlspecialchars($r['uid']) ?>?')">
Aprovar
</a>
<a href="?reject=<?= urlencode($r['uid']) ?>"
class="btn btn-sm btn-danger"
onclick="return confirm('Rejeitar e apagar <?= htmlspecialchars($r['uid']) ?>?')">
Rejeitar
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- APROVADOS -->
<h4 class="text-success mb-3"> Dispositivos Aprovados (<?= count($approved) ?>)</h4>
<div class="card shadow-sm">
<div class="card-body">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th>UID</th>
<th>NOTA</th>
<th>ESTADO</th>
<th>BROKER</th>
<th>ÚLTIMO</th>
<th>AÇÕES</th>
</tr>
</thead>
<tbody>
<?php foreach ($approved as $r): ?>
<?php
$ago = (int)$r['ago'];
$lastSeen = $ago < 60 ? "{$ago}s" : round($ago/60) . "m";
$online = (int)$r['online'] === 1;
$blocked = (int)$r['blocked'] === 1;
if ($blocked) {
$state = '<span class="badge bg-danger">BLOQUEADO</span>';
} elseif ($online) {
$state = '<span class="badge bg-success">ONLINE</span>';
} else {
$state = '<span class="badge bg-secondary">OFFLINE</span>';
}
$broker = ($r['current_broker'] ?? '') === 'recovery'
? '<span class="badge bg-warning text-dark">RECOVERY</span>'
: '<span class="badge bg-primary">PRIMARY</span>';
?>
<tr>
<td class="fw-bold"><?= htmlspecialchars($r['uid']) ?></td>
<td><?= htmlspecialchars($r['note'] ?? '') ?></td>
<td><?= $state ?></td>
<td><?= $broker ?></td>
<td><?= $lastSeen ?></td>
<td>
<div class="d-flex gap-2">
<a href="?toggle=<?= urlencode($r['uid']) ?>"
class="btn btn-sm <?= $blocked ? 'btn-success' : 'btn-danger' ?>">
<?= $blocked ? 'Desbloquear' : 'Bloquear' ?>
</a>
<a href="?demote=<?= urlencode($r['uid']) ?>"
class="btn btn-sm btn-warning"
onclick="return confirm('Despromover para pendente?')">
Pendente
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div class="mt-4">
<a href="api/menu.php" class="btn btn-outline-secondary"> Menu</a>
</div>
</div>
</body>
</html>

16
save_note.php Normal file
View File

@ -0,0 +1,16 @@
<?php
require_once __DIR__ . "/api/protecao.php";
require __DIR__ . "/api/db.php";
$uid = $_POST['uid'] ?? '';
$note = $_POST['note'] ?? '';
$stmt = $pdo->prepare("
UPDATE contadores
SET note = ?
WHERE uid = ?
");
$stmt->execute([$note, $uid]);
header("Location: console.php");