安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
1768 lines
71 KiB
PHP
1768 lines
71 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
namespace addon\iot\service;
|
||
|
||
use think\facade\Db;
|
||
use think\facade\Log;
|
||
use Workerman\Worker;
|
||
use Workerman\Timer;
|
||
use Workerman\Connection\TcpConnection;
|
||
use Workerman\Protocols\Websocket;
|
||
use ywxapp\service\AddonService;
|
||
use addon\iot\protocol\Mqtt;
|
||
use addon\iot\service\Bridge;
|
||
use addon\iot\service\DeviceService;
|
||
use addon\iot\service\Forwarder;
|
||
use addon\iot\service\RuleEngine;
|
||
|
||
/*
|
||
* MQTT Broker 服务(兼容 MQTT 3.1.1 与 5.0)—— 类 EMQX 轻量单机版
|
||
*
|
||
* 基础能力(原有):
|
||
* - CONNECT/CONNACK、PUBLISH + QoS0/1/2、SUBSCRIBE/UNSUBSCRIBE、PING、DISCONNECT、AUTH
|
||
* - 通配符 + / #、保留消息、遗嘱、会话保持、TCP 分包(交由 Mqtt 协议类)
|
||
*
|
||
* 增强能力(本次):
|
||
* - 认证:账号密码校验(Auth 服务,兼容匿名开关)
|
||
* - ACL :发布/订阅主题级权限(Auth 服务,$SYS 只读)
|
||
* - 传输:WebSocket 接入(ws://,MQTT over WebSocket)+ 可选 TLS(mqtts)
|
||
* - 持久化:保留消息重启恢复 + clean=false 会话离线消息补投(Store 服务)
|
||
* - 共享订阅:$share/{group}/{filter} 组内负载均衡(round-robin)
|
||
* - 可观测:$SYS/broker/# 系统主题周期发布运行指标
|
||
*
|
||
* 仍为单进程内存态(worker->count=1),会话/订阅存内存,保留/离线落 DB。
|
||
*/
|
||
/**
|
||
* Broker 类
|
||
*
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
class Broker
|
||
{
|
||
/* ---------- MQTT 控制报文类型 ---------- */
|
||
const CMD_CONNECT = 1;
|
||
const CMD_CONNACK = 2;
|
||
const CMD_PUBLISH = 3;
|
||
const CMD_PUBACK = 4;
|
||
const CMD_PUBREC = 5;
|
||
const CMD_PUBREL = 6;
|
||
const CMD_PUBCOMP = 7;
|
||
const CMD_SUBSCRIBE = 8;
|
||
const CMD_SUBACK = 9;
|
||
const CMD_UNSUBSCRIBE = 10;
|
||
const CMD_UNSUBACK = 11;
|
||
const CMD_PINGREQ = 12;
|
||
const CMD_PINGRESP = 13;
|
||
const CMD_DISCONNECT = 14;
|
||
const CMD_AUTH = 15;
|
||
|
||
/* ---------- CONNACK 返回/原因码(3.1.1 / 5.0) ---------- */
|
||
const RC_ACCEPTED = 0;
|
||
const RC_UNACCEPTABLE_PROTO = [4 => 1, 5 => 0x84]; // 3.1.1:1 5.0:0x84
|
||
const RC_NOT_AUTHORIZED = [4 => 5, 5 => 0x87]; // 3.1.1:5 5.0:0x87
|
||
const RC_BAD_CREDENTIALS = [4 => 4, 5 => 0x86]; // 3.1.1:4 5.0:0x86
|
||
|
||
/** @var Worker TCP 监听 */
|
||
protected $worker;
|
||
|
||
/** @var Worker|null WebSocket 监听 */
|
||
protected $wsWorker;
|
||
|
||
/** @var array 运行配置 */
|
||
protected $config = [];
|
||
|
||
/** @var Auth */
|
||
protected $auth;
|
||
|
||
/** @var Store */
|
||
protected $store;
|
||
|
||
/** @var Bridge 跨进程消息桥接 */
|
||
protected $bridge;
|
||
|
||
/** @var Forwarder 外部 Broker 转发(规则引擎使用) */
|
||
protected $forwarder;
|
||
|
||
/** @var RuleEngine 规则引擎 */
|
||
protected $ruleEngine;
|
||
|
||
/** @var bool 是否主进程(仅主进程发布 $SYS) */
|
||
protected $isPrimary = true;
|
||
|
||
/**
|
||
* @var array 会话表
|
||
* client_id => [
|
||
* 'conn'=>TcpConnection|null, 'clean'=>bool, 'username'=>?string,
|
||
* 'subscriptions'=>[key => ['filter'=>string,'share'=>?string,'qos'=>int,'noLocal'=>bool,'rap'=>bool,'rh'=>int]]
|
||
* ]
|
||
*/
|
||
protected $sessions = [];
|
||
|
||
/** @var array 保留消息 topic => ['payload'=>string,'qos'=>int] */
|
||
protected $retained = [];
|
||
|
||
/** @var array 共享订阅 round-robin 游标 groupKey => int */
|
||
protected $rrIndex = [];
|
||
|
||
/** @var array 运行统计 */
|
||
protected $stats = ['recv' => 0, 'sent' => 0, 'startTime' => 0];
|
||
|
||
|
||
public function start(string $host = '0.0.0.0', int $port = 1883, string $mode = 'both'): void
|
||
{
|
||
// Workerman 运行时文件:pidFile 供 stop/restart 命令定位 master 进程;daemon 模式日志落点
|
||
$runtimeDir = rtrim((string) runtime_path(), DIRECTORY_SEPARATOR);
|
||
Worker::$pidFile = $runtimeDir . DIRECTORY_SEPARATOR . 'iot.pid';
|
||
Worker::$stdoutFile = $runtimeDir . DIRECTORY_SEPARATOR . 'iot.log';
|
||
// ⚠️ Workerman::parseCommand() 直接扫描 argv,取「第一个命中的白名单词」
|
||
// (start/stop/restart/reload/status/connections)当作自己的命令;一个都命中不了就 exit(usage)。
|
||
// · 合并写法 `php think iot start`:argv 里本就有 start,无需注入;
|
||
// · `php think iot restart`:会命中 restart —— Workerman 的 restart 分支在 pid 文件已被
|
||
// Iot::doRestart() 删除的前提下等价于 start(只是多等一次旧 master 退出),故可共用;
|
||
// · 旧写法 `php think iot:start` / `php think iot:restart`:命令名带冒号,命中不了白名单,
|
||
// 必须靠官方 library 模式入口 Worker::$command 注入兜底,否则 Workerman 会打印 usage 退出
|
||
// (daemon 模式下这行 usage 只会出现在 runtime/iot.log 里,终端看不到)。
|
||
if (DIRECTORY_SEPARATOR === '/' && Worker::$command === '') {
|
||
Worker::$command = 'start';
|
||
}
|
||
|
||
$this->config = $this->loadConfig();
|
||
$this->auth = new Auth($this->config);
|
||
$this->store = new Store($this->config);
|
||
$this->bridge = new Bridge($this->config);
|
||
$this->forwarder = new Forwarder($this->config);
|
||
$this->ruleEngine = new RuleEngine($this->config, $this->forwarder);
|
||
|
||
if ($host === '0.0.0.0' && !empty($this->config['host'])) {
|
||
$host = $this->config['host'];
|
||
}
|
||
if ($port === 1883 && !empty($this->config['port'])) {
|
||
$port = (int) $this->config['port'];
|
||
}
|
||
|
||
// 启动模式:both=TCP+WS 同进程(仅 Linux);tcp=仅 TCP;ws=仅 WebSocket(Windows 双进程)
|
||
$isWindows = DIRECTORY_SEPARATOR === '\\';
|
||
$wsPort = (int) ($this->config['ws_port'] ?? 0);
|
||
$wsForce = (string) ($this->config['ws_force'] ?? '0') === '1';
|
||
$wsEnabled = $wsPort > 0 && (!$isWindows || $wsForce);
|
||
$wantTcp = $mode !== 'ws';
|
||
$wantWs = $wsEnabled && $mode !== 'tcp';
|
||
|
||
// Workerman 限制:Windows 下一个 PHP 进程只能创建一个 worker(监听一个端口),
|
||
// TCP 与 WebSocket 必须拆成两个独立进程,经 Redis Stream 桥接(cluster_enabled)互通。
|
||
if ($isWindows && $mode === 'both' && $wsEnabled) {
|
||
$this->log(1, '[Windows] Workerman 一个进程只能监听一个端口,无法同时启动 TCP + WebSocket。');
|
||
$this->log(1, '[Windows] 请开两个 cmd 窗口分别运行:');
|
||
$this->log(1, '[Windows] 窗口1: php think iot start --tcp-only');
|
||
$this->log(1, '[Windows] 窗口2: php think iot start --ws-only');
|
||
$this->log(1, '[Windows] 双进程需后台「服务设置」开启『启用集群桥接』(cluster_enabled=1) 且 Redis 可连,消息才会互通。');
|
||
$this->log(1, '[Windows] 本进程仅启动 TCP 监听,WebSocket 请另开窗口运行 --ws-only。');
|
||
$wantWs = false;
|
||
}
|
||
|
||
// 主 TCP 监听(可选 TLS)
|
||
$context = [];
|
||
$scheme = 'tcp';
|
||
if ((string) ($this->config['ssl_enabled'] ?? '0') === '1'
|
||
&& !empty($this->config['ssl_cert']) && !empty($this->config['ssl_key'])) {
|
||
$context['ssl'] = [
|
||
'local_cert' => $this->config['ssl_cert'],
|
||
'local_pk' => $this->config['ssl_key'],
|
||
'verify_peer' => false,
|
||
];
|
||
$scheme = 'tls'; // Workerman transport 见下方 setter
|
||
}
|
||
if ($wantTcp) {
|
||
$this->worker = new Worker("tcp://{$host}:{$port}", $context);
|
||
$this->worker->name = 'MQTTBroker';
|
||
$this->worker->count = 1; // 内存态,单进程保证状态一致
|
||
$this->worker->protocol = Mqtt::class;
|
||
if ($scheme === 'tls') {
|
||
$this->worker->transport = 'ssl';
|
||
}
|
||
$this->worker->onConnect = [$this, 'onTcpConnect'];
|
||
$this->worker->onMessage = [$this, 'onTcpMessage'];
|
||
$this->worker->onClose = [$this, 'onTcpClose'];
|
||
$this->worker->onWorkerStart = [$this, 'onWorkerStart'];
|
||
}
|
||
|
||
// WebSocket 监听(MQTT over WebSocket)
|
||
if ($wantWs) {
|
||
$this->wsWorker = new Worker("websocket://{$host}:{$wsPort}");
|
||
$this->wsWorker->name = 'MQTTBroker-WS';
|
||
$this->wsWorker->count = 1;
|
||
$this->wsWorker->onConnect = [$this, 'onWsConnect'];
|
||
$this->wsWorker->onWebSocketConnect = [$this, 'onWsHandshake'];
|
||
$this->wsWorker->onMessage = [$this, 'onWsMessage'];
|
||
$this->wsWorker->onClose = [$this, 'onTcpClose'];
|
||
// 关键:WS 进程也须触发 onWorkerStart,否则 Bridge 不连接 Redis、不轮询总线,
|
||
// 保留消息不恢复 → TCP 与 WS 双进程消息无法互通
|
||
$this->wsWorker->onWorkerStart = [$this, 'onWorkerStart'];
|
||
}
|
||
|
||
$parts = [];
|
||
if ($wantTcp) {
|
||
$parts[] = "MQTT(TCP) {$host}:{$port} ({$scheme})";
|
||
}
|
||
if ($wantWs) {
|
||
$parts[] = "WebSocket {$host}:{$wsPort}";
|
||
}
|
||
$this->log(2, 'MQTT Broker 启动: ' . implode(' / ', $parts) . '(MQTT 3.1.1/5.0)');
|
||
Worker::runAll();
|
||
}
|
||
|
||
/**
|
||
* worker 启动:建表自愈、恢复保留消息、保活检测、$SYS 周期发布
|
||
*/
|
||
public function onWorkerStart(Worker $worker): void
|
||
{
|
||
$this->stats['startTime'] = time();
|
||
$this->isPrimary = ($worker->name === 'MQTTBroker'); // TCP 为主进程
|
||
|
||
// 建表自愈 + 恢复保留消息
|
||
$this->auth->ensureTables();
|
||
$this->store->ensureTables();
|
||
$this->ruleEngine->ensureTables();
|
||
$this->ensureStatsTable();
|
||
$this->retained = $this->store->loadRetained();
|
||
|
||
// 跨进程桥接(须在 fork 后的 worker 进程内建立连接)
|
||
if ($this->bridge->connect()) {
|
||
Timer::add(0.05, function () {
|
||
$this->bridge->poll([$this, 'onBridgeMessage']);
|
||
});
|
||
$this->log(2, "跨进程桥接已启用 node={$this->bridge->nodeId()}");
|
||
}
|
||
|
||
// 保活检测:超过 1.5*keepalive 无报文则断开(TCP/WS 通用)
|
||
Timer::add(5, function () use ($worker) {
|
||
$now = time();
|
||
foreach ($worker->connections as $conn) {
|
||
if (!empty($conn->mqttConnected) && !empty($conn->mqttKeepAlive)) {
|
||
$idle = $now - ($conn->mqttLastTime ?? $now);
|
||
if ($idle > $conn->mqttKeepAlive * 1.5) {
|
||
$this->log(1, '[保活] ' . ($conn->clientId ?? '?') . ' (ws=' . (!empty($conn->isWs) ? 'Y' : 'N') . ') 空闲 ' . $idle . 's > keepalive*1.5=' . ($conn->mqttKeepAlive * 1.5) . 's,主动断开');
|
||
$conn->close();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// $SYS 系统主题周期发布(仅主进程,避免多进程重复)
|
||
if ($this->isPrimary && (string) ($this->config['sys_enabled'] ?? '1') === '1') {
|
||
$interval = max(2, (int) ($this->config['sys_interval'] ?? 10));
|
||
Timer::add($interval, [$this, 'publishSysTopics']);
|
||
}
|
||
|
||
// 实时指标:每个进程把本进程指标写入 Redis 共享区(TTL 10s 自动清理,进程退出即失效)
|
||
$this->persistLocalStats();
|
||
Timer::add(2, [$this, 'persistLocalStats']);
|
||
|
||
// 仅主进程:聚合全部进程指标落库(DB 单行 id=1,后台仪表盘跨进程读取)
|
||
if ($this->isPrimary) {
|
||
$this->persistStats();
|
||
Timer::add(2, [$this, 'persistStats']);
|
||
// 手动发布出站队列消费(跨进程内部路由,零外部依赖)
|
||
Timer::add(0.5, [$this, 'consumeOutbox']);
|
||
// 场景引擎:定时触发器每分钟扫描 cron
|
||
Timer::add(60, function () {
|
||
SceneService::onTick();
|
||
});
|
||
// 传感器时序保留期清理(每天一次;按 create_at 索引删,默认保留 90 天)
|
||
Timer::add(86400, function () {
|
||
SensorPointService::cleanup();
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* TCP / WebSocket 连接层回调
|
||
* ========================================================== */
|
||
|
||
public function onTcpConnect(TcpConnection $connection): void
|
||
{
|
||
$connection->mqttConnected = false;
|
||
$connection->clientId = null;
|
||
$connection->mqttProperDisconnect = false;
|
||
$connection->isWs = false;
|
||
$connection->mqttPubTokens = (int) ($this->config['publish_rate_limit'] ?? 0);
|
||
$connection->mqttPubWindow = time();
|
||
|
||
// 连接数硬限流:超过上限直接拒绝,避免内存雪崩
|
||
$max = (int) ($this->config['max_connections'] ?? 0);
|
||
if ($max > 0 && count($this->sessions) >= $max) {
|
||
$this->log(1, "连接数已达上限({$max}),拒绝新连接 " . ($connection->getRemoteIp() ?? ''));
|
||
$connection->close();
|
||
}
|
||
}
|
||
|
||
|
||
public function onWsConnect(TcpConnection $connection): void
|
||
{
|
||
$this->onTcpConnect($connection);
|
||
$connection->isWs = true;
|
||
$connection->wsBuf = '';
|
||
$connection->websocketType = Websocket::BINARY_TYPE_ARRAYBUFFER; // MQTT over WS 用二进制帧
|
||
}
|
||
|
||
/**
|
||
* WebSocket 握手:回应子协议 mqtt(部分客户端要求)
|
||
*/
|
||
public function onWsHandshake(TcpConnection $connection, $httpBuffer): void
|
||
{
|
||
try {
|
||
if (stripos($httpBuffer, 'sec-websocket-protocol') !== false) {
|
||
$connection->headers = $connection->headers ?? [];
|
||
$connection->headers[] = 'Sec-WebSocket-Protocol: mqtt';
|
||
}
|
||
} catch (\Throwable $e) {
|
||
}
|
||
}
|
||
|
||
/**
|
||
* TCP:$data 已由 Mqtt::decode() 解析为 ['cmd','flags','body']
|
||
*/
|
||
public function onTcpMessage(TcpConnection $connection, $data): void
|
||
{
|
||
$connection->mqttLastTime = time();
|
||
$this->handlePacket(
|
||
$connection,
|
||
(int) ($data['cmd'] ?? 0),
|
||
(int) ($data['flags'] ?? 0),
|
||
$data['body'] ?? ''
|
||
);
|
||
}
|
||
|
||
/**
|
||
* WebSocket:payload 是原始 MQTT 字节,需自行分包(ws 帧≠mqtt 报文边界)
|
||
*/
|
||
public function onWsMessage(TcpConnection $connection, $data): void
|
||
{
|
||
$connection->mqttLastTime = time();
|
||
$connection->wsBuf = ($connection->wsBuf ?? '') . $data;
|
||
while (($len = Mqtt::input($connection->wsBuf)) !== 0) {
|
||
if ($len < 0) {
|
||
$connection->close();
|
||
return;
|
||
}
|
||
$packet = substr($connection->wsBuf, 0, $len);
|
||
$connection->wsBuf = substr($connection->wsBuf, $len);
|
||
$decoded = Mqtt::decode($packet);
|
||
$this->handlePacket(
|
||
$connection,
|
||
(int) ($decoded['cmd'] ?? 0),
|
||
(int) ($decoded['flags'] ?? 0),
|
||
$decoded['body'] ?? ''
|
||
);
|
||
if ($connection->wsBuf === '') {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
public function onTcpClose(TcpConnection $connection): void
|
||
{
|
||
$cid = $connection->clientId ?? null;
|
||
if (!$cid || !isset($this->sessions[$cid])) {
|
||
return;
|
||
}
|
||
// 异常断开且有遗嘱 → 发布遗嘱
|
||
if (empty($connection->mqttProperDisconnect) && !empty($connection->mqttWill)) {
|
||
$w = $connection->mqttWill;
|
||
$this->distribute($w['topic'], $w['payload'], $w['qos'], $w['retain'], $cid);
|
||
}
|
||
if (!empty($connection->mqttClean)) {
|
||
unset($this->sessions[$cid]);
|
||
} else {
|
||
$this->sessions[$cid]['conn'] = null; // 保留订阅,标记离线
|
||
}
|
||
$this->logClose($cid);
|
||
// 设备离线告警(绑定设备断开时触发,带抑制窗口)
|
||
if (str_starts_with($cid, 'dev_')) {
|
||
AlertService::onDeviceOffline($cid);
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* 统一发送:TCP 走协议类 encode(数组);WS 需手动 encode 成字节
|
||
* ========================================================== */
|
||
|
||
protected function rawSend(TcpConnection $connection, array $packet): void
|
||
{
|
||
if (!empty($connection->isWs)) {
|
||
$connection->send(Mqtt::encode($packet));
|
||
} else {
|
||
$connection->send($packet);
|
||
}
|
||
$this->stats['sent']++;
|
||
}
|
||
|
||
|
||
protected function sendPacket(TcpConnection $connection, int $cmd, int $flags, string $body): void
|
||
{
|
||
$this->rawSend($connection, ['cmd' => $cmd, 'flags' => $flags, 'body' => $body]);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 报文分发(分包/解码已由 Mqtt 协议类完成)
|
||
* ========================================================== */
|
||
|
||
protected function handlePacket(TcpConnection $connection, int $cmd, int $flags, string $payload): void
|
||
{
|
||
if (!$connection->mqttConnected && $cmd !== self::CMD_CONNECT) {
|
||
$connection->close();
|
||
return;
|
||
}
|
||
switch ($cmd) {
|
||
case self::CMD_CONNECT: $this->handleConnect($connection, $flags, $payload); break;
|
||
case self::CMD_PUBLISH: $this->handlePublish($connection, $flags, $payload); break;
|
||
case self::CMD_PUBACK: $this->handlePubAck($connection, $payload); break;
|
||
case self::CMD_PUBREC: $this->handlePubRec($connection, $payload); break;
|
||
case self::CMD_PUBREL: $this->handlePubRel($connection, $payload); break;
|
||
case self::CMD_PUBCOMP: $this->handlePubComp($connection, $payload); break;
|
||
case self::CMD_SUBSCRIBE: $this->handleSubscribe($connection, $payload); break;
|
||
case self::CMD_UNSUBSCRIBE: $this->handleUnsubscribe($connection, $payload); break;
|
||
case self::CMD_PINGREQ: $this->sendPacket($connection, self::CMD_PINGRESP, 0, ''); break;
|
||
case self::CMD_DISCONNECT:
|
||
$connection->mqttProperDisconnect = true;
|
||
$connection->close();
|
||
break;
|
||
case self::CMD_AUTH:
|
||
if (($connection->mqttProto ?? 0) == 5) {
|
||
$this->sendPacket($connection, self::CMD_AUTH, 0, "\x00\x00");
|
||
}
|
||
break;
|
||
default:
|
||
$connection->close();
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* CONNECT / CONNACK
|
||
* ========================================================== */
|
||
|
||
protected function handleConnect(TcpConnection $connection, int $flags, string $payload): void
|
||
{
|
||
$p = 0;
|
||
try {
|
||
list($protoName, $p) = $this->readStr($payload, $p);
|
||
$level = ord($payload[$p]); $p++;
|
||
$cflags = ord($payload[$p]); $p++;
|
||
$keepAlive = unpack('n', substr($payload, $p, 2))[1]; $p += 2;
|
||
|
||
if ($level === 5) {
|
||
if ($protoName !== 'MQTT') { $this->connAckError($connection, 5, self::RC_UNACCEPTABLE_PROTO[5]); return; }
|
||
$version = 5;
|
||
} elseif ($level === 4) {
|
||
if ($protoName !== 'MQTT') { $this->connAckError($connection, 4, self::RC_UNACCEPTABLE_PROTO[4]); return; }
|
||
$version = 4;
|
||
} elseif ($level === 3) {
|
||
if ($protoName !== 'MQIsdp') { $this->connAckError($connection, 4, self::RC_UNACCEPTABLE_PROTO[4]); return; }
|
||
$version = 4;
|
||
} else {
|
||
$v = ($level === 5) ? 5 : 4;
|
||
$this->connAckError($connection, $v, self::RC_UNACCEPTABLE_PROTO[$v]);
|
||
return;
|
||
}
|
||
|
||
if ($version === 5) {
|
||
list($pl, $p) = $this->readVarInt($payload, $p);
|
||
$p += $pl; // 跳过连接属性
|
||
}
|
||
|
||
list($clientId, $p) = $this->readStr($payload, $p);
|
||
if ($clientId === '') {
|
||
$this->connAckError($connection, $version, $version === 5 ? 0x85 : 2);
|
||
return;
|
||
}
|
||
|
||
$will = null;
|
||
if ($cflags & 0x04) {
|
||
if ($version === 5) {
|
||
list($wpl, $p) = $this->readVarInt($payload, $p);
|
||
$p += $wpl; // 跳过遗嘱属性
|
||
}
|
||
list($willTopic, $p) = $this->readStr($payload, $p);
|
||
list($willPayload, $p) = $this->readStr($payload, $p);
|
||
$will = [
|
||
'topic' => $willTopic,
|
||
'payload' => $willPayload,
|
||
'qos' => ($cflags >> 3) & 0x03,
|
||
'retain' => ($cflags & 0x20) ? 1 : 0,
|
||
];
|
||
}
|
||
$username = null;
|
||
if ($cflags & 0x80) {
|
||
list($username, $p) = $this->readStr($payload, $p);
|
||
}
|
||
$password = null;
|
||
if ($cflags & 0x40) {
|
||
list($password, $p) = $this->readStr($payload, $p);
|
||
}
|
||
|
||
// ---- 认证 ----
|
||
$authRes = $this->auth->authenticate($username, $password, $clientId);
|
||
if (!$authRes['ok']) {
|
||
$rc = (stripos($authRes['reason'], 'password') !== false || stripos($authRes['reason'], 'credential') !== false)
|
||
? self::RC_BAD_CREDENTIALS[$version]
|
||
: self::RC_NOT_AUTHORIZED[$version];
|
||
$this->connAckError($connection, $version, $rc);
|
||
$this->log(1, "认证失败 client={$clientId} user={$username} reason={$authRes['reason']}");
|
||
return;
|
||
}
|
||
|
||
$clean = ($cflags & 0x02) ? true : false;
|
||
$sessionPresent = 0;
|
||
if (isset($this->sessions[$clientId])) {
|
||
try { $this->sessions[$clientId]['conn'] && $this->sessions[$clientId]['conn']->close(); } catch (\Throwable $e) {}
|
||
if (!$clean) {
|
||
$sessionPresent = 1;
|
||
$subs = $this->sessions[$clientId]['subscriptions'];
|
||
} else {
|
||
$subs = [];
|
||
}
|
||
} else {
|
||
$subs = [];
|
||
}
|
||
|
||
$this->sessions[$clientId] = [
|
||
'conn' => $connection,
|
||
'clean' => $clean,
|
||
'username' => $username,
|
||
'subscriptions' => $subs,
|
||
// 身份快照:投递复核(subscriberMayReceive)在离线会话上没有 conn 时要用
|
||
'superuser' => !empty($authRes['superuser']),
|
||
'authReason' => (string) ($authRes['reason'] ?? ''),
|
||
'memberId' => (int) ($authRes['member_id'] ?? 0),
|
||
];
|
||
|
||
$connection->mqttConnected = true;
|
||
$connection->clientId = $clientId;
|
||
$connection->mqttUsername = $username;
|
||
$connection->mqttSuperuser = !empty($authRes['superuser']);
|
||
// 认证来源与会员ID:ACL 私有主题归属校验要用(device本体看 reason=ok,会员看 reason=member/ticket)
|
||
$connection->mqttAuthReason = (string) ($authRes['reason'] ?? '');
|
||
$connection->mqttMemberId = (int) ($authRes['member_id'] ?? 0);
|
||
$connection->mqttProto = $version;
|
||
$connection->mqttKeepAlive = $keepAlive > 0 ? $keepAlive : (int) ($this->config['max_keepalive'] ?? 60);
|
||
$connection->mqttLastTime = time();
|
||
$connection->mqttOutId = 1;
|
||
$connection->mqttOutFlight = [];
|
||
$connection->mqttInbound = [];
|
||
$connection->mqttWill = $will;
|
||
$connection->mqttClean = $clean;
|
||
$connection->mqttProperDisconnect = false;
|
||
|
||
$this->sendConnAck($connection, $sessionPresent, self::RC_ACCEPTED);
|
||
$this->logConnect($clientId, $username, $connection->getRemoteIp());
|
||
// 认证身份留痕:排查「连上了却订阅不了」时,第一眼要看的就是这个身份是不是自己以为的那个
|
||
// (member/ticket=会员身份;ok=iot_auth 认证账号;anonymous-named=填了用户名但密码错/开关未开)
|
||
$this->log(1, sprintf(
|
||
'认证通过 client=%s user=%s 身份=%s member=%d super=%d ip=%s',
|
||
$clientId,
|
||
(string) $username,
|
||
(string) ($authRes['reason'] ?? ''),
|
||
(int) ($authRes['member_id'] ?? 0),
|
||
!empty($authRes['superuser']) ? 1 : 0,
|
||
$connection->getRemoteIp()
|
||
));
|
||
|
||
// 会话恢复:补投离线消息 + 已存订阅的保留消息
|
||
if (!$clean && $sessionPresent) {
|
||
$this->flushOffline($connection, $clientId);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$this->log(1, "CONNECT 解析失败:{$e->getMessage()}");
|
||
$connection->close();
|
||
}
|
||
}
|
||
|
||
|
||
protected function sendConnAck(TcpConnection $connection, int $sessionPresent, int $reasonCode): void
|
||
{
|
||
$version = $connection->mqttProto ?? 4;
|
||
if ($version === 5) {
|
||
$body = chr($sessionPresent ? 1 : 0) . chr($reasonCode) . "\x00";
|
||
} else {
|
||
$body = chr($sessionPresent ? 1 : 0) . chr($reasonCode);
|
||
}
|
||
$this->sendPacket($connection, self::CMD_CONNACK, 0, $body);
|
||
}
|
||
|
||
|
||
protected function connAckError(TcpConnection $connection, int $version, int $reasonCode): void
|
||
{
|
||
$connection->mqttProto = $version;
|
||
$this->sendConnAck($connection, 0, $reasonCode);
|
||
$connection->close();
|
||
}
|
||
|
||
/* ============================================================
|
||
* PUBLISH 与 QoS 流
|
||
* ========================================================== */
|
||
|
||
protected function handlePublish(TcpConnection $connection, int $flags, string $payload): void
|
||
{
|
||
// 单连接发布速率限制(令牌桶,滑动窗口 1s)
|
||
$rate = $connection->mqttPubTokens ?? 0;
|
||
if ($rate > 0) {
|
||
$now = time();
|
||
if (($connection->mqttPubWindow ?? 0) !== $now) {
|
||
$connection->mqttPubWindow = $now;
|
||
$connection->mqttPubCount = 0;
|
||
}
|
||
$connection->mqttPubCount = ($connection->mqttPubCount ?? 0) + 1;
|
||
if ($connection->mqttPubCount > $rate) {
|
||
$this->log(2, "连接 {$connection->clientId} 发布超限({$rate}/s),丢弃消息");
|
||
return; // 静默丢弃,避免放大风暴
|
||
}
|
||
}
|
||
|
||
$qos = ($flags >> 1) & 3;
|
||
$retain = $flags & 1;
|
||
$p = 0;
|
||
list($topic, $p) = $this->readStr($payload, $p);
|
||
$packetId = 0;
|
||
if ($qos > 0) {
|
||
$packetId = unpack('n', substr($payload, $p, 2))[1];
|
||
$p += 2;
|
||
}
|
||
if (($connection->mqttProto ?? 4) === 5) {
|
||
list($pl, $p) = $this->readVarInt($payload, $p);
|
||
$p += $pl; // 跳过发布属性
|
||
}
|
||
$msg = substr($payload, $p);
|
||
|
||
// ---- ACL 发布校验 ----
|
||
if (!$this->auth->checkAcl($connection->mqttUsername ?? null, $connection->clientId, $topic, Auth::ACT_PUB, !empty($connection->mqttSuperuser), (string) ($connection->mqttAuthReason ?? ''), (int) ($connection->mqttMemberId ?? 0))) {
|
||
// 拒绝:5.0 回带原因码的 ACK,3.1.1 静默丢弃
|
||
if ($qos === 1) {
|
||
$this->sendReasonPacket(self::CMD_PUBACK, $packetId, $connection->mqttProto, 0x87, $connection);
|
||
} elseif ($qos === 2) {
|
||
$this->sendReasonPacket(self::CMD_PUBREC, $packetId, $connection->mqttProto, 0x87, $connection);
|
||
}
|
||
$this->log(1, sprintf(
|
||
'发布被 ACL 拒绝 client=%s topic=%s user=%s 身份=%s member=%d 原因=%s',
|
||
(string) $connection->clientId,
|
||
$topic,
|
||
(string) ($connection->mqttUsername ?? ''),
|
||
(string) ($connection->mqttAuthReason ?? ''),
|
||
(int) ($connection->mqttMemberId ?? 0),
|
||
$this->auth->lastDenyReason()
|
||
));
|
||
return;
|
||
}
|
||
|
||
if ($qos === 1) {
|
||
$this->sendReasonPacket(self::CMD_PUBACK, $packetId, $connection->mqttProto, 0, $connection);
|
||
$this->distribute($topic, $msg, $qos, $retain, $connection->clientId);
|
||
} elseif ($qos === 2) {
|
||
$this->sendReasonPacket(self::CMD_PUBREC, $packetId, $connection->mqttProto, 0, $connection);
|
||
$connection->mqttInbound[$packetId] = ['topic' => $topic, 'msg' => $msg, 'qos' => $qos, 'retain' => $retain];
|
||
} else {
|
||
$this->distribute($topic, $msg, $qos, $retain, $connection->clientId);
|
||
}
|
||
// 钩子:消息被 Broker 接受并分发(跳过 $SYS 内部指标主题以降低噪声)
|
||
// qos2 的实际分发在 PUBREL 阶段,钩子改在 handlePubRel 中触发,避免重复
|
||
if (($qos === 0 || $qos === 1) && !str_starts_with($topic, '$SYS')) {
|
||
event('mqtt_message_published', [
|
||
'topic' => $topic,
|
||
'payload' => $msg,
|
||
'qos' => $qos,
|
||
'retain' => (bool)$retain,
|
||
'clientId' => $connection->clientId,
|
||
]);
|
||
// 设备维度日志(状态/事件上报留痕)
|
||
DeviceLogService::log($topic, $msg, 'in');
|
||
// 传感器结构化时序落库 + 云端阈值复核(曲线/统计/漏报兜底)
|
||
SensorPointService::capture($topic, $msg);
|
||
// 上线引脚表缓存(/status 里的 pins → iot_device.pins;state 报文因此不必带 pin)
|
||
DeviceService::onStatusMessage($topic, $msg);
|
||
// 场景引擎:设备状态/事件触发评估(动作异步入 outbox)
|
||
SceneService::onMessagePublished($topic, $msg);
|
||
// OTA 进度上报处理(升级完成/失败 → 告警通知)
|
||
if (preg_match('#^/devices/([^/]+)/ota/report$#', $topic, $m)) {
|
||
OtaService::onReport($m[1], $msg);
|
||
}
|
||
// 设备主动事件上报 → 告警 + 实时通知
|
||
if (preg_match('#^/devices/([^/]+)/event$#', $topic, $m)) {
|
||
AlertService::onDeviceEvent($m[1], $msg);
|
||
}
|
||
// 传感器配置回执(设备写入 NVS 后回 config_ack → 标记配置已生效)
|
||
if (preg_match('#^/devices/([^/]+)/ack$#', $topic, $m)) {
|
||
$ack = json_decode($msg, true);
|
||
if (is_array($ack)) {
|
||
SensorConfigService::onAck($m[1], $ack);
|
||
}
|
||
}
|
||
// 设备注册主题 system/device/register/{sn}:激活入网并回执
|
||
if (preg_match('#^system/device/register/([^/]+)$#', $topic, $m)) {
|
||
$this->handleDeviceRegister($m[1], $msg);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设备注册主题处理:system/device/register/{mac}
|
||
*
|
||
* 新协议(即插即用):设备上电 publish retained 注册消息
|
||
* {"mac":"a0b1c2d3e4f5","type":"esp32c3_temp","model":"KF-C3-3KANG","ver":"1.0"}
|
||
* 服务端自动创建设备入网(mac 唯一身份),无需后台预登记;
|
||
* 旧协议兼容:payload 为裸 token 或 {"token":"..."} 时走 activate_token 激活。
|
||
* 处理结果发布到 system/device/register/{mac}/resp(设备需预先订阅该主题,回执不 retained)。
|
||
* 成功回执:{"code":0,"message":"ok","data":{uuid,secret(仅首次),topics,dev_type,...}}
|
||
* 失败回执:{"code":1,"message":"错误原因","data":null}
|
||
*/
|
||
protected function handleDeviceRegister(string $sn, string $msg): void
|
||
{
|
||
$decoded = json_decode($msg, true);
|
||
$info = is_array($decoded) ? $decoded : [];
|
||
$token = trim((string) ($info['token'] ?? ''));
|
||
if ($token === '' && !is_array($decoded)) {
|
||
$token = trim($msg); // 裸 token 字符串兼容
|
||
}
|
||
// 新协议:payload 携带 mac → mac 即插即用注册
|
||
$payloadMac = str_replace([':', '-', ' '], '', strtolower((string) ($info['mac'] ?? '')));
|
||
if ($payloadMac !== '') {
|
||
if ($payloadMac !== $sn) {
|
||
$out = ['code' => 1, 'message' => 'payload mac 与主题不一致', 'data' => null];
|
||
} else {
|
||
try {
|
||
$res = DeviceService::registerByMac($sn, $info);
|
||
$out = ['code' => 0, 'message' => 'ok', 'data' => $res];
|
||
} catch (\Throwable $e) {
|
||
$out = ['code' => 1, 'message' => $e->getMessage(), 'data' => null];
|
||
}
|
||
}
|
||
} else {
|
||
// 旧协议兼容:activate_token 激活入网
|
||
try {
|
||
$res = DeviceService::registerViaMqtt($sn, $token);
|
||
$out = ['code' => 0, 'message' => 'ok', 'data' => $res];
|
||
} catch (\Throwable $e) {
|
||
$out = ['code' => 1, 'message' => $e->getMessage(), 'data' => null];
|
||
}
|
||
}
|
||
$this->log(1, "设备注册: sn={$sn} code={$out['code']} {$out['message']}");
|
||
$this->distribute(DeviceService::registerRespTopic($sn), json_encode($out, JSON_UNESCAPED_UNICODE), 1, 0, null);
|
||
}
|
||
|
||
|
||
protected function handlePubAck(TcpConnection $connection, string $payload): void
|
||
{
|
||
$id = unpack('n', substr($payload, 0, 2))[1];
|
||
unset($connection->mqttOutFlight[$id]);
|
||
}
|
||
|
||
|
||
protected function handlePubRec(TcpConnection $connection, string $payload): void
|
||
{
|
||
$id = unpack('n', substr($payload, 0, 2))[1];
|
||
if (isset($connection->mqttOutFlight[$id])) {
|
||
$this->sendReasonPacket(self::CMD_PUBREL, $id, $connection->mqttProto, 0, $connection);
|
||
$connection->mqttOutFlight[$id]['state'] = 'pubrel';
|
||
}
|
||
}
|
||
|
||
|
||
protected function handlePubRel(TcpConnection $connection, string $payload): void
|
||
{
|
||
$id = unpack('n', substr($payload, 0, 2))[1];
|
||
if (isset($connection->mqttInbound[$id])) {
|
||
$m = $connection->mqttInbound[$id];
|
||
unset($connection->mqttInbound[$id]);
|
||
$this->distribute($m['topic'], $m['msg'], $m['qos'], $m['retain'], $connection->clientId);
|
||
// 钩子:qos2 消息在 PUBREL 确认后才真正分发,此处触发(跳过 $SYS 内部主题)
|
||
if (!str_starts_with($m['topic'], '$SYS')) {
|
||
event('mqtt_message_published', [
|
||
'topic' => $m['topic'],
|
||
'payload' => $m['msg'],
|
||
'qos' => $m['qos'],
|
||
'retain' => (bool)$m['retain'],
|
||
'clientId' => $connection->clientId,
|
||
]);
|
||
DeviceLogService::log($m['topic'], $m['msg'], 'in');
|
||
SensorPointService::capture($m['topic'], $m['msg']);
|
||
DeviceService::onStatusMessage($m['topic'], $m['msg']); // 同上(QoS1 重传路径)
|
||
SceneService::onMessagePublished($m['topic'], $m['msg']);
|
||
if (preg_match('#^/devices/([^/]+)/ota/report$#', $m['topic'], $mm)) {
|
||
OtaService::onReport($mm[1], $m['msg']);
|
||
}
|
||
if (preg_match('#^/devices/([^/]+)/event$#', $m['topic'], $mm)) {
|
||
AlertService::onDeviceEvent($mm[1], $m['msg']);
|
||
}
|
||
// 传感器配置回执(同上,QoS1 重传路径)
|
||
if (preg_match('#^/devices/([^/]+)/ack$#', $m['topic'], $mm)) {
|
||
$ack = json_decode($m['msg'], true);
|
||
if (is_array($ack)) {
|
||
SensorConfigService::onAck($mm[1], $ack);
|
||
}
|
||
}
|
||
if (preg_match('#^system/device/register/([^/]+)$#', $m['topic'], $mm)) {
|
||
$this->handleDeviceRegister($mm[1], $m['msg']);
|
||
}
|
||
}
|
||
}
|
||
$this->sendReasonPacket(self::CMD_PUBCOMP, $id, $connection->mqttProto, 0, $connection);
|
||
}
|
||
|
||
|
||
protected function handlePubComp(TcpConnection $connection, string $payload): void
|
||
{
|
||
$id = unpack('n', substr($payload, 0, 2))[1];
|
||
unset($connection->mqttOutFlight[$id]);
|
||
}
|
||
|
||
|
||
protected function sendReasonPacket(int $cmd, int $id, int $version, int $reasonCode, TcpConnection $connection): void
|
||
{
|
||
$body = pack('n', $id);
|
||
if ($version === 5) {
|
||
$body .= chr($reasonCode) . "\x00";
|
||
}
|
||
$this->sendPacket($connection, $cmd, ($cmd === self::CMD_PUBREL ? 2 : 0), $body);
|
||
}
|
||
|
||
/* ============================================================
|
||
* SUBSCRIBE / UNSUBSCRIBE
|
||
* ========================================================== */
|
||
|
||
protected function handleSubscribe(TcpConnection $connection, string $payload): void
|
||
{
|
||
$p = 0;
|
||
$packetId = unpack('n', substr($payload, $p, 2))[1];
|
||
$p += 2;
|
||
if (($connection->mqttProto ?? 4) === 5) {
|
||
list($pl, $p) = $this->readVarInt($payload, $p);
|
||
$p += $pl; // 跳过订阅属性
|
||
}
|
||
$granted = [];
|
||
$cid = $connection->clientId;
|
||
while ($p < strlen($payload)) {
|
||
list($rawFilter, $p) = $this->readStr($payload, $p);
|
||
$opt = ord($payload[$p]); $p++;
|
||
$reqQos = $opt & 0x03;
|
||
$noLocal = ($opt & 0x04) ? true : false;
|
||
$rap = ($opt & 0x08) ? true : false;
|
||
$rh = ($opt >> 4) & 0x03;
|
||
|
||
// 解析共享订阅 $share/{group}/{filter}
|
||
$share = null;
|
||
$filter = $rawFilter;
|
||
if (strncmp($rawFilter, '$share/', 7) === 0) {
|
||
$rest = substr($rawFilter, 7);
|
||
$slash = strpos($rest, '/');
|
||
if ($slash !== false) {
|
||
$share = substr($rest, 0, $slash);
|
||
$filter = substr($rest, $slash + 1);
|
||
}
|
||
}
|
||
|
||
// ---- ACL 订阅校验 ----
|
||
if (!$this->auth->checkAcl($connection->mqttUsername ?? null, $cid, $filter, Auth::ACT_SUB, !empty($connection->mqttSuperuser), (string) ($connection->mqttAuthReason ?? ''), (int) ($connection->mqttMemberId ?? 0))) {
|
||
$granted[] = ($connection->mqttProto ?? 4) === 5 ? 0x87 : 0x80; // 拒绝
|
||
// 日志必须带「身份 + 原因」:SUBACK 128 的四种成因(匿名降级/非主人/设备不存在/ACL 兜底)
|
||
// 在客户端看起来完全一样,只有这里能区分。
|
||
$this->log(1, sprintf(
|
||
'订阅被 ACL 拒绝 client=%s topic=%s user=%s 身份=%s member=%d 原因=%s',
|
||
$cid,
|
||
$rawFilter,
|
||
(string) ($connection->mqttUsername ?? ''),
|
||
(string) ($connection->mqttAuthReason ?? ''),
|
||
(int) ($connection->mqttMemberId ?? 0),
|
||
$this->auth->lastDenyReason()
|
||
));
|
||
continue;
|
||
}
|
||
|
||
$this->sessions[$cid]['subscriptions'][$rawFilter] = [
|
||
'filter' => $filter, 'share' => $share,
|
||
'qos' => $reqQos, 'noLocal' => $noLocal, 'rap' => $rap, 'rh' => $rh,
|
||
];
|
||
$this->logSubscribe($cid, $rawFilter, $reqQos);
|
||
$granted[] = $reqQos;
|
||
|
||
// 共享订阅不投递保留消息(MQTT 规范)
|
||
if ($share === null) {
|
||
$this->deliverRetained($connection, $filter, $reqQos, $rh);
|
||
}
|
||
}
|
||
$this->sendSubAck($connection, $packetId, $granted);
|
||
}
|
||
|
||
|
||
protected function sendSubAck(TcpConnection $connection, int $packetId, array $granted): void
|
||
{
|
||
$body = pack('n', $packetId);
|
||
if (($connection->mqttProto ?? 4) === 5) {
|
||
$body .= "\x00";
|
||
}
|
||
foreach ($granted as $g) {
|
||
$body .= chr($g);
|
||
}
|
||
$this->sendPacket($connection, self::CMD_SUBACK, 0, $body);
|
||
}
|
||
|
||
|
||
protected function handleUnsubscribe(TcpConnection $connection, string $payload): void
|
||
{
|
||
$p = 0;
|
||
$packetId = unpack('n', substr($payload, $p, 2))[1];
|
||
$p += 2;
|
||
if (($connection->mqttProto ?? 4) === 5) {
|
||
list($pl, $p) = $this->readVarInt($payload, $p);
|
||
$p += $pl;
|
||
}
|
||
$cid = $connection->clientId;
|
||
while ($p < strlen($payload)) {
|
||
list($filter, $p) = $this->readStr($payload, $p);
|
||
unset($this->sessions[$cid]['subscriptions'][$filter]);
|
||
$this->logUnsubscribe($cid, $filter);
|
||
}
|
||
$body = pack('n', $packetId);
|
||
if (($connection->mqttProto ?? 4) === 5) {
|
||
$body .= "\x00";
|
||
}
|
||
$this->sendPacket($connection, self::CMD_UNSUBACK, 0, $body);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 消息分发 / 投递 / 保留 / 离线
|
||
* ========================================================== */
|
||
/**
|
||
* 消息分发入口:本地路由 + 跨进程桥接
|
||
*/
|
||
protected function distribute(string $topic, string $msg, int $qos, int $retain, ?string $fromClientId): void
|
||
{
|
||
$this->routeMessage($topic, $msg, $qos, $retain, $fromClientId, false);
|
||
// 桥接给其它进程(TCP/WS/多进程),由对端投递给其本地订阅者
|
||
if ($this->bridge->isEnabled()) {
|
||
$this->bridge->publish($topic, $msg, $qos, $retain, $fromClientId);
|
||
}
|
||
// 规则引擎:匹配源主题后转发到外部 Broker(如 EMQX)
|
||
$this->ruleEngine->matchAndForward($topic, $msg, $qos);
|
||
}
|
||
|
||
/**
|
||
* 桥接消息回调:仅做本地投递,不再持久化/记录/回环桥接
|
||
*/
|
||
public function onBridgeMessage(string $topic, string $msg, int $qos, int $retain, ?string $fromClientId): void
|
||
{
|
||
$this->routeMessage($topic, $msg, $qos, $retain, $fromClientId, true);
|
||
}
|
||
|
||
/**
|
||
* 本地路由:保留消息同步 + 本进程订阅者投递
|
||
* @param bool $bridged 是否来自其它进程(true 时跳过 DB 持久化/日志,仅内存同步 retained)
|
||
*/
|
||
protected function routeMessage(string $topic, string $msg, int $qos, int $retain, ?string $fromClientId, bool $bridged): void
|
||
{
|
||
$isSys = strncmp($topic, '$SYS', 4) === 0;
|
||
|
||
// 保留消息处理($SYS 不落保留);内存双进程都同步,DB 仅源进程写
|
||
if ($retain && !$isSys) {
|
||
if ($msg === '') {
|
||
unset($this->retained[$topic]);
|
||
if (!$bridged) {
|
||
$this->store->deleteRetained($topic);
|
||
}
|
||
} else {
|
||
$this->retained[$topic] = ['payload' => $msg, 'qos' => $qos];
|
||
if (!$bridged) {
|
||
$this->store->saveRetained($topic, $msg, $qos);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!$isSys && !$bridged) {
|
||
$this->stats['recv']++;
|
||
$source = ((string) $fromClientId === 'manual') ? 'manual' : 'client';
|
||
$this->logMessage((string) $fromClientId, $topic, $msg, $qos, $retain, $source);
|
||
}
|
||
|
||
// 共享订阅分组收集: groupKey => [ [cid,conn,sub], ... ]
|
||
$sharedGroups = [];
|
||
|
||
// 私有主题(/devices/、/notify/)投递复核开关:订阅过滤器可能含通配符(# / +),
|
||
// 订阅时刻无法表达归属,必须在投递时按「真实主题」再校验一次
|
||
$recheckPrivate = $this->isPrivateTopic($topic);
|
||
|
||
foreach ($this->sessions as $cid => $sess) {
|
||
$conn = $sess['conn'] ?? null;
|
||
foreach ($sess['subscriptions'] as $sub) {
|
||
if (!$this->topicMatch($sub['filter'], $topic)) {
|
||
continue;
|
||
}
|
||
// ★ 防 `#` 通配符订阅绕过:订阅了 # 也不代表有权收到 /devices/{别人的}/state
|
||
if ($recheckPrivate && !$this->subscriberMayReceive($conn, $sess, (string) $cid, $topic)) {
|
||
continue;
|
||
}
|
||
$effQos = min($qos, $sub['qos']);
|
||
|
||
// 共享订阅:仅在线成员参与,稍后 round-robin 选一个
|
||
if ($sub['share'] !== null) {
|
||
if ($conn) {
|
||
$gk = $sub['share'] . '|' . $sub['filter'];
|
||
$sharedGroups[$gk][] = ['cid' => $cid, 'conn' => $conn, 'qos' => $effQos];
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// noLocal:不投递给发布者自身
|
||
if (!empty($sub['noLocal']) && $cid === $fromClientId) {
|
||
continue;
|
||
}
|
||
|
||
if (!$conn) {
|
||
// 离线会话:clean=false 且 QoS>0 入离线队列
|
||
if (empty($sess['clean']) && $effQos > 0 && !$isSys) {
|
||
$this->store->queueOffline($cid, $topic, $msg, $effQos);
|
||
}
|
||
continue;
|
||
}
|
||
$this->deliver($conn, $topic, $msg, $effQos, $retain, $cid);
|
||
}
|
||
}
|
||
|
||
// 共享订阅投递:每组按 round-robin 选一个成员
|
||
foreach ($sharedGroups as $gk => $members) {
|
||
$n = count($members);
|
||
if ($n === 0) {
|
||
continue;
|
||
}
|
||
$idx = ($this->rrIndex[$gk] ?? 0) % $n;
|
||
$this->rrIndex[$gk] = $idx + 1;
|
||
$m = $members[$idx];
|
||
$this->deliver($m['conn'], $topic, $msg, $m['qos'], $retain, $m['cid']);
|
||
}
|
||
}
|
||
|
||
|
||
/** 私有命名空间判断(带归属语义的主题前缀) */
|
||
protected function isPrivateTopic(string $topic): bool
|
||
{
|
||
return strncmp($topic, '/devices/', 9) === 0 || strncmp($topic, '/notify/', 8) === 0;
|
||
}
|
||
|
||
/**
|
||
* 投递复核:订阅者是否有权接收该「真实主题」(不含通配符)
|
||
*
|
||
* 存在的原因:SUBSCRIBE 校验看到的是「过滤器」,而 `#`、`+/...` 这类通配符不以
|
||
* /devices/ 开头 → 绕过 Auth::checkPrivateTopic() 的硬归属分支,落到 acl_default 兜底。
|
||
* 一旦兜底为「允许」,订阅 `#` 的匿名客户端就能收到全站设备消息 → 必须在投递时复核。
|
||
*
|
||
* @param TcpConnection|null $conn 在线连接(离线会话为 null,改用会话身份快照)
|
||
* @param array $sess 会话条目(含 username/superuser/authReason/memberId)
|
||
*/
|
||
protected function subscriberMayReceive(?TcpConnection $conn, array $sess, string $cid, string $topic): bool
|
||
{
|
||
if ($conn) {
|
||
$username = $conn->mqttUsername ?? null;
|
||
$clientId = (string) ($conn->clientId ?? $cid);
|
||
$superuser = !empty($conn->mqttSuperuser);
|
||
$authReason = (string) ($conn->mqttAuthReason ?? '');
|
||
$memberId = (int) ($conn->mqttMemberId ?? 0);
|
||
} else {
|
||
$username = $sess['username'] ?? null;
|
||
$clientId = $cid;
|
||
$superuser = !empty($sess['superuser']);
|
||
$authReason = (string) ($sess['authReason'] ?? '');
|
||
$memberId = (int) ($sess['memberId'] ?? 0);
|
||
}
|
||
return $this->auth->checkAcl($username, $clientId, $topic, Auth::ACT_SUB, $superuser, $authReason, $memberId);
|
||
}
|
||
|
||
protected function deliver(TcpConnection $conn, string $topic, string $msg, int $qos, int $retain, string $subClientId): void
|
||
{
|
||
if ($qos === 0) {
|
||
$this->rawSend($conn, $this->buildPublish($conn->mqttProto, $topic, $msg, 0, 0, $retain, false));
|
||
return;
|
||
}
|
||
$id = $this->nextId($conn);
|
||
$conn->mqttOutFlight[$id] = ['state' => 'publish'];
|
||
$this->rawSend($conn, $this->buildPublish($conn->mqttProto, $topic, $msg, $qos, $id, $retain, false));
|
||
}
|
||
|
||
|
||
protected function deliverRetained(TcpConnection $conn, string $filter, int $reqQos, int $rh): void
|
||
{
|
||
if ($rh === 2) {
|
||
return; // RetainHandling=2:不发送保留消息
|
||
}
|
||
foreach ($this->retained as $topic => $r) {
|
||
if (!$this->topicMatch($filter, $topic)) {
|
||
continue;
|
||
}
|
||
// 保留消息同样按真实主题复核:订阅 # 时不能靠 retained 兜出私有主题的历史数据
|
||
if ($this->isPrivateTopic($topic)
|
||
&& !$this->subscriberMayReceive($conn, [], (string) ($conn->clientId ?? ''), $topic)) {
|
||
continue;
|
||
}
|
||
$effQos = min($r['qos'], $reqQos);
|
||
$this->deliver($conn, $topic, $r['payload'], $effQos, 1, $conn->clientId);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 会话重连:补投离线消息
|
||
*/
|
||
protected function flushOffline(TcpConnection $conn, string $clientId): void
|
||
{
|
||
$msgs = $this->store->popOffline($clientId);
|
||
foreach ($msgs as $m) {
|
||
// 兜底:离线队列里可能残留越权的私有主题消息(历史入队),补投前再复核一次
|
||
if ($this->isPrivateTopic((string) $m['topic'])
|
||
&& !$this->subscriberMayReceive($conn, [], $clientId, (string) $m['topic'])) {
|
||
continue;
|
||
}
|
||
$this->deliver($conn, $m['topic'], $m['payload'], (int) $m['qos'], 0, $clientId);
|
||
}
|
||
if (!empty($msgs)) {
|
||
$this->log(2, "补投离线消息 client={$clientId} count=" . count($msgs));
|
||
}
|
||
}
|
||
|
||
|
||
protected function buildPublish(int $version, string $topic, string $msg, int $qos, int $packetId, int $retain, bool $dup): array
|
||
{
|
||
$flags = ($dup ? 8 : 0) | ($qos << 1) | ($retain ? 1 : 0);
|
||
$body = $this->writeStr($topic);
|
||
if ($qos > 0) {
|
||
$body .= pack('n', $packetId);
|
||
}
|
||
if ($version === 5) {
|
||
$body .= "\x00"; // 属性长度 0
|
||
}
|
||
$body .= $msg;
|
||
return ['cmd' => self::CMD_PUBLISH, 'flags' => $flags, 'body' => $body];
|
||
}
|
||
|
||
/* ============================================================
|
||
* $SYS 系统主题
|
||
* ========================================================== */
|
||
|
||
public function publishSysTopics(): void
|
||
{
|
||
$agg = $this->aggregatedStats(); // 全局聚合数(多进程模式下所有进程之和)
|
||
$uptime = time() - ($this->stats['startTime'] ?: time());
|
||
$metrics = [
|
||
'$SYS/broker/uptime' => (string) $uptime,
|
||
'$SYS/broker/clients/connected' => (string) $agg['clients_online'],
|
||
'$SYS/broker/clients/total' => (string) $agg['clients_total'],
|
||
'$SYS/broker/subscriptions/count' => (string) $agg['subscriptions'],
|
||
'$SYS/broker/messages/received' => (string) $agg['messages_received'],
|
||
'$SYS/broker/messages/sent' => (string) $agg['messages_sent'],
|
||
'$SYS/broker/retained/count' => (string) $agg['retained'],
|
||
'$SYS/broker/timestamp' => (string) time(),
|
||
];
|
||
foreach ($metrics as $t => $v) {
|
||
$this->distribute($t, $v, 0, 0, null);
|
||
}
|
||
}
|
||
|
||
|
||
protected function countSubscriptions(): int
|
||
{
|
||
$c = 0;
|
||
foreach ($this->sessions as $s) {
|
||
$c += count($s['subscriptions']);
|
||
}
|
||
return $c;
|
||
}
|
||
|
||
/* ============================================================
|
||
* 实时指标快照(后台仪表盘跨进程读取,DB 单行 id=1)
|
||
* ========================================================== */
|
||
|
||
protected function ensureStatsTable(): void
|
||
{
|
||
try {
|
||
Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_iot_stats` (
|
||
`id` int unsigned NOT NULL,
|
||
`uptime` int DEFAULT '0',
|
||
`clients_online` int DEFAULT '0',
|
||
`clients_total` int DEFAULT '0',
|
||
`subscriptions` int DEFAULT '0',
|
||
`messages_received` int DEFAULT '0',
|
||
`messages_sent` int DEFAULT '0',
|
||
`retained` int DEFAULT '0',
|
||
`timestamp` int DEFAULT '0',
|
||
PRIMARY KEY (`id`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||
} catch (\Throwable $e) {
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 本进程运行指标(连接数/订阅数/收发数/保留数)
|
||
*/
|
||
protected function localStats(): array
|
||
{
|
||
$online = 0;
|
||
foreach ($this->sessions as $sess) {
|
||
if (!empty($sess['conn'])) {
|
||
$online++;
|
||
}
|
||
}
|
||
return [
|
||
'clients_online' => $online,
|
||
'clients_total' => count($this->sessions),
|
||
'subscriptions' => $this->countSubscriptions(),
|
||
'messages_received' => $this->stats['recv'],
|
||
'messages_sent' => $this->stats['sent'],
|
||
'retained' => count($this->retained),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 把本进程指标写入 Redis 共享区(TTL 10s)。所有进程周期调用。
|
||
*/
|
||
public function persistLocalStats(): void
|
||
{
|
||
$this->bridge->writeStats($this->localStats());
|
||
}
|
||
|
||
/**
|
||
* 聚合后的全局指标:启用桥接时汇总所有进程,否则退化为本进程
|
||
*/
|
||
protected function aggregatedStats(): array
|
||
{
|
||
if ($this->bridge->isEnabled()) {
|
||
return $this->bridge->aggregateStats();
|
||
}
|
||
return $this->localStats();
|
||
}
|
||
|
||
/**
|
||
* 将聚合后的全局指标写入 DB(单行 id=1)。供后台实时监控仪表盘轮询读取。
|
||
*/
|
||
public function persistStats(): void
|
||
{
|
||
$this->persistLocalStats(); // 先刷新本进程指标,再聚合
|
||
$agg = $this->aggregatedStats();
|
||
$data = [
|
||
'uptime' => time() - ($this->stats['startTime'] ?: time()),
|
||
'clients_online' => $agg['clients_online'],
|
||
'clients_total' => $agg['clients_total'],
|
||
'subscriptions' => $agg['subscriptions'],
|
||
'messages_received' => $agg['messages_received'],
|
||
'messages_sent' => $agg['messages_sent'],
|
||
'retained' => $agg['retained'],
|
||
'timestamp' => time(),
|
||
];
|
||
try {
|
||
$row = Db::name('iot_stats')->where('id', 1)->find();
|
||
if ($row) {
|
||
Db::name('iot_stats')->where('id', 1)->update($data);
|
||
} else {
|
||
Db::name('iot_stats')->insert(array_merge(['id' => 1], $data));
|
||
}
|
||
} catch (\Throwable $e) {
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 消费手动发布出站队列:取出待投递消息并经内部路由分发给本地/桥接订阅者。
|
||
* 后台"发布消息"与对外 REST API 均通过此通道投递,零外部依赖(不依赖 workerman/mqtt)。
|
||
*/
|
||
public function consumeOutbox(): void
|
||
{
|
||
$rows = $this->store->popOutbox(100);
|
||
if (empty($rows)) {
|
||
return;
|
||
}
|
||
$ids = [];
|
||
foreach ($rows as $r) {
|
||
$ids[] = $r['id'];
|
||
$this->distribute((string) $r['topic'], (string) $r['payload'], (int) $r['qos'], (int) $r['retain'], 'manual');
|
||
}
|
||
$this->store->markOutboxSent($ids);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 工具:字符串 / 变长整数 / 主题匹配
|
||
* ========================================================== */
|
||
|
||
protected function readStr(string $buf, int $pos): array
|
||
{
|
||
$len = unpack('n', substr($buf, $pos, 2))[1];
|
||
$pos += 2;
|
||
$str = substr($buf, $pos, $len);
|
||
$pos += $len;
|
||
return [$str, $pos];
|
||
}
|
||
|
||
|
||
protected function writeStr(string $str): string
|
||
{
|
||
return pack('n', strlen($str)) . $str;
|
||
}
|
||
|
||
|
||
protected function readVarInt(string $buf, int $pos): array
|
||
{
|
||
$multiplier = 1;
|
||
$value = 0;
|
||
$i = 0;
|
||
do {
|
||
$byte = ord($buf[$pos + $i]);
|
||
$value += ($byte & 0x7F) * $multiplier;
|
||
$multiplier *= 128;
|
||
$i++;
|
||
if ($i > 4) {
|
||
break;
|
||
}
|
||
} while (($byte & 0x80) !== 0);
|
||
return [$value, $pos + $i];
|
||
}
|
||
|
||
|
||
protected function writeVarInt(int $value): string
|
||
{
|
||
$bytes = '';
|
||
do {
|
||
$byte = $value % 128;
|
||
$value = intdiv($value, 128);
|
||
if ($value > 0) {
|
||
$byte |= 0x80;
|
||
}
|
||
$bytes .= chr($byte);
|
||
} while ($value > 0);
|
||
return $bytes;
|
||
}
|
||
|
||
|
||
protected function topicMatch(string $filter, string $topic): bool
|
||
{
|
||
if ($filter === $topic) {
|
||
return true;
|
||
}
|
||
$f = explode('/', $filter);
|
||
$t = explode('/', $topic);
|
||
foreach ($f as $i => $seg) {
|
||
if ($seg === '#') {
|
||
return true;
|
||
}
|
||
if ($seg === '+') {
|
||
if (!isset($t[$i])) {
|
||
return false;
|
||
}
|
||
continue;
|
||
}
|
||
if (!isset($t[$i]) || $seg !== $t[$i]) {
|
||
return false;
|
||
}
|
||
}
|
||
return count($f) === count($t);
|
||
}
|
||
|
||
|
||
protected function nextId(TcpConnection $conn): int
|
||
{
|
||
$id = $conn->mqttOutId;
|
||
$conn->mqttOutId = ($id >= 65535) ? 1 : $id + 1;
|
||
return $id;
|
||
}
|
||
|
||
/* ============================================================
|
||
* 配置与日志
|
||
* ========================================================== */
|
||
|
||
protected function loadConfig(): array
|
||
{
|
||
try {
|
||
$saved = AddonService::config('iot');
|
||
} catch (\Throwable $e) {
|
||
$saved = [];
|
||
}
|
||
$defaults = [
|
||
'port' => 1883,
|
||
'host' => '0.0.0.0',
|
||
'ws_port' => 8083,
|
||
'ws_force' => '0', // Windows 开发机强制启用 WebSocket(1=开启,生产 Linux 恒启用)
|
||
'ssl_enabled' => '0',
|
||
'ssl_cert' => '',
|
||
'ssl_key' => '',
|
||
'allow_anonymous' => '1',
|
||
'acl_enabled' => '0',
|
||
'acl_default' => 'allow',
|
||
'max_keepalive' => 60,
|
||
'sys_enabled' => '1',
|
||
'sys_interval' => 10,
|
||
'persist_retain' => '1',
|
||
'persist_offline' => '1',
|
||
'offline_limit' => 1000,
|
||
'max_connections' => 5000, // 单进程连接数硬上限,超出拒绝新连接防止雪崩
|
||
'publish_rate_limit' => 50, // 单连接每秒最大发布消息数(令牌桶,0=不限)
|
||
'cluster_enabled' => '0',
|
||
'rule_enabled' => '1',
|
||
'redis_host' => '127.0.0.1',
|
||
'redis_port' => 6379,
|
||
'redis_db' => 0,
|
||
'redis_password' => '',
|
||
'redis_prefix' => 'iot',
|
||
'log_level' => '1',
|
||
];
|
||
return array_merge($defaults, is_array($saved) ? $saved : []);
|
||
}
|
||
|
||
|
||
protected function log(int $level, string $msg): void
|
||
{
|
||
$cfgLevel = (int) ($this->config['log_level'] ?? '1');
|
||
if ($cfgLevel === 0 || $level > $cfgLevel) {
|
||
return;
|
||
}
|
||
Log::info("[iot] {$msg}");
|
||
}
|
||
|
||
/* ============================================================
|
||
* 进程管理(供 iot stop / iot restart 使用;纯 PHP,不依赖 shell_exec/pgrep)
|
||
* ========================================================== */
|
||
|
||
/**
|
||
* Broker pid 文件绝对路径(start / stop / restart 必须同一处,避免路径不一致)
|
||
*/
|
||
public static function pidFilePath(): string
|
||
{
|
||
return rtrim((string) runtime_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'iot.pid';
|
||
}
|
||
|
||
/**
|
||
* 当前插件配置(供 iot status 展示;读失败返回 null,以便区分「读取失败」与「键缺失」)
|
||
*/
|
||
public static function currentConfig(): ?array
|
||
{
|
||
try {
|
||
return AddonService::config('iot') ?: [];
|
||
} catch (\Throwable $e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 本 Broker 实际监听的端口(停止/重启后复核端口是否真的释放)
|
||
*
|
||
* @return int[]
|
||
*/
|
||
public static function configuredPorts(): array
|
||
{
|
||
$saved = self::currentConfig() ?? [];
|
||
$ports = [
|
||
(int) ($saved['port'] ?? 1883) ?: 1883,
|
||
(int) ($saved['ws_port'] ?? 8083),
|
||
];
|
||
return array_values(array_unique(array_filter($ports, static fn (int $p): bool => $p > 0)));
|
||
}
|
||
|
||
/**
|
||
* 端口是否仍被占用(true=占用)
|
||
*/
|
||
public static function portBusy(int $port): bool
|
||
{
|
||
set_error_handler(static fn (): bool => true);
|
||
$server = stream_socket_server("tcp://0.0.0.0:{$port}", $errno, $error);
|
||
restore_error_handler();
|
||
if ($server === false) {
|
||
return true;
|
||
}
|
||
fclose($server);
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 端口复核结果(供命令输出)
|
||
*
|
||
* @return array{lines:string[],busy:int[]}
|
||
*/
|
||
public static function portReport(): array
|
||
{
|
||
$lines = [];
|
||
$busy = [];
|
||
foreach (self::configuredPorts() as $port) {
|
||
if (self::portBusy($port)) {
|
||
$busy[] = $port;
|
||
$lines[] = "端口 {$port} 仍被占用";
|
||
} else {
|
||
$lines[] = "端口 {$port} 已释放";
|
||
}
|
||
}
|
||
return ['lines' => $lines, 'busy' => $busy];
|
||
}
|
||
|
||
/** /proc 扫描:pid => 进程标题 */
|
||
protected static function scanProcTitle(): array
|
||
{
|
||
$list = [];
|
||
if (!is_dir('/proc')) {
|
||
return $list;
|
||
}
|
||
foreach ((array) glob('/proc/[0-9]*/cmdline') as $file) {
|
||
$pid = (int) basename(dirname($file));
|
||
if ($pid === getmypid()) {
|
||
continue;
|
||
}
|
||
$cmd = (string) @file_get_contents($file);
|
||
if ($cmd === '') {
|
||
continue;
|
||
}
|
||
$list[$pid] = trim(str_replace("\0", ' ', $cmd));
|
||
}
|
||
return $list;
|
||
}
|
||
|
||
/** 读父进程 pid(/proc/{pid}/stat 的 comm 字段可能含空格/右括号,须从最后一个 ')' 切) */
|
||
protected static function ppidOf(int $pid): int
|
||
{
|
||
return (int) (self::statFields($pid)[1] ?? 0);
|
||
}
|
||
|
||
/**
|
||
* 读 /proc/{pid}/stat 中 comm 之后的字段(下标 0 = state,即 stat 的第 3 个字段)
|
||
*
|
||
* @return string[]
|
||
*/
|
||
protected static function statFields(int $pid): array
|
||
{
|
||
$stat = (string) @file_get_contents('/proc/' . $pid . '/stat');
|
||
$pos = strrpos($stat, ')');
|
||
if ($pos === false) {
|
||
return [];
|
||
}
|
||
return explode(' ', trim(substr($stat, $pos + 2)));
|
||
}
|
||
|
||
/**
|
||
* 进程启动时刻(unix 时间戳,取不到返回 0)。
|
||
* starttime = stat 第 22 个字段(= comm 之后的下标 19),单位是时钟滴答(Linux 默认 HZ=100)。
|
||
*/
|
||
public static function processStartTime(int $pid): int
|
||
{
|
||
$fields = self::statFields($pid);
|
||
$ticks = (int) ($fields[19] ?? 0);
|
||
if ($ticks <= 0) {
|
||
return 0;
|
||
}
|
||
$uptime = (float) @file_get_contents('/proc/uptime');
|
||
if ($uptime <= 0) {
|
||
return 0;
|
||
}
|
||
return (int) (time() - ($uptime - $ticks / 100));
|
||
}
|
||
|
||
/**
|
||
* 采集 Broker 相关进程。
|
||
*
|
||
* ⚠️ Workerman 启动后会用 cli_set_process_title() 改写进程标题:
|
||
* master → 「WorkerMan: master process start_file=/站点/think」
|
||
* worker → 「WorkerMan: worker process MQTTBroker tcp://0.0.0.0:1883」
|
||
* 因此 `pgrep -f 'iot start'` / `pkill -f iot:start` 找不到任何进程,
|
||
* 必须按标题特征匹配(这也是 pid 文件丢失后唯一的救命通道)。
|
||
*
|
||
* @return array<int,array{role:string,cmd:string}> pid => ['role' => master|worker|orphan, 'cmd' => 标题]
|
||
*/
|
||
public static function findBrokerProcesses(): array
|
||
{
|
||
$titles = self::scanProcTitle();
|
||
if (empty($titles)) {
|
||
return [];
|
||
}
|
||
$root = rtrim(str_replace('\\', '/', (string) app()->getRootPath()), '/');
|
||
$filePid = (int) trim((string) @file_get_contents(self::pidFilePath()));
|
||
|
||
// ① worker:标题前缀固定为 Workerman: worker process + 我们设置的 name(MQTTBroker / MQTTBroker-WS)
|
||
$workers = [];
|
||
$ppidList = [];
|
||
foreach ($titles as $pid => $cmd) {
|
||
if (str_contains($cmd, 'MQTTBroker')) {
|
||
$ppid = self::ppidOf($pid);
|
||
$workers[$pid] = ['ppid' => $ppid, 'cmd' => $cmd];
|
||
$ppidList[] = $ppid;
|
||
}
|
||
}
|
||
|
||
// ② master:pidFile 优先;pidFile 丢失时按「标题特征 + 存在我们的 worker 子进程」双重确认,
|
||
// 避免误伤同一站点根下的其它 Workerman 服务(它们的 start_file 也是 .../think)
|
||
$found = [];
|
||
foreach ($titles as $pid => $cmd) {
|
||
$norm = str_replace('\\', '/', $cmd);
|
||
$fromFile = ($filePid > 0 && $pid === $filePid);
|
||
// 标题尚未被改写的短暂窗口:原始命令行含插件命令(iot:start / iot start / iot restart…)
|
||
$rawStart = (bool) preg_match('#iot[: ](start|stop|restart|status)#', $norm);
|
||
$titleOurs = str_contains($norm, 'WorkerMan') || str_contains($norm, 'start_file=');
|
||
$rootMatch = $root !== '' && str_contains($norm, $root) && str_contains($norm, 'start_file=');
|
||
$hasWorker = in_array($pid, $ppidList, true);
|
||
|
||
if (!$fromFile && !$rawStart && !$rootMatch) {
|
||
continue; // 与本插件无关
|
||
}
|
||
if (!$titleOurs && !$hasWorker) {
|
||
continue; // pid 已被系统回收给别的程序 → 绝不碰
|
||
}
|
||
if (!$fromFile && !$hasWorker) {
|
||
continue; // 同站点根下其它 Workerman 服务 → 不误伤
|
||
}
|
||
$found[$pid] = ['role' => 'master', 'cmd' => $cmd];
|
||
}
|
||
|
||
// ③ worker 归类:master 已死的 worker 会成为孤儿并继续持有监听端口,必须能杀到
|
||
foreach ($workers as $pid => $w) {
|
||
if (isset($found[$pid])) {
|
||
continue;
|
||
}
|
||
$found[$pid] = [
|
||
'role' => isset($found[$w['ppid']]) ? 'worker' : 'orphan',
|
||
'cmd' => $w['cmd'],
|
||
];
|
||
}
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* 停止进程:SIGINT 优雅退出 → 超时 SIGKILL 兜底 → 复核存活。
|
||
* (Workerman 自带的 stop 在失败时只写一行日志就退出,看不到、也没兜底)
|
||
*
|
||
* @param int[] $pids
|
||
* @return array{ok:bool,supported:bool,alive:int[],steps:string[]}
|
||
*/
|
||
public static function stopProcesses(array $pids, int $timeout = 8, bool $force = false): array
|
||
{
|
||
$result = ['ok' => false, 'supported' => true, 'alive' => [], 'steps' => []];
|
||
if (!function_exists('posix_kill')) {
|
||
$result['supported'] = false;
|
||
return $result;
|
||
}
|
||
$pids = array_values(array_unique(array_filter(array_map('intval', $pids))));
|
||
$pids = array_values(array_filter($pids, static fn (int $p): bool => $p > 0 && $p !== getmypid()));
|
||
if (empty($pids)) {
|
||
$result['ok'] = true;
|
||
return $result;
|
||
}
|
||
|
||
// ① 发停止信号(2=SIGINT 优雅;9=SIGKILL 强杀)
|
||
foreach ($pids as $pid) {
|
||
if (@posix_kill($pid, $force ? 9 : 2)) {
|
||
$result['steps'][] = $force
|
||
? "PID {$pid} 已发送 SIGKILL(强杀)"
|
||
: "PID {$pid} 已发送 SIGINT(优雅停止)";
|
||
} else {
|
||
$result['steps'][] = "PID {$pid} 发信号失败(可能已消失或无权限)";
|
||
}
|
||
}
|
||
|
||
// ② 等优雅退出
|
||
$deadline = time() + max(1, $timeout);
|
||
while (time() < $deadline) {
|
||
if (empty(array_filter($pids, static fn (int $p): bool => @posix_kill($p, 0)))) {
|
||
break;
|
||
}
|
||
usleep(200000);
|
||
}
|
||
|
||
// ③ 超时强杀兜底
|
||
foreach ($pids as $pid) {
|
||
if (@posix_kill($pid, 0)) {
|
||
@posix_kill($pid, 9);
|
||
$result['steps'][] = "PID {$pid} 等待超时,已强杀(SIGKILL)";
|
||
}
|
||
}
|
||
usleep(600000);
|
||
|
||
// ④ 复核
|
||
foreach ($pids as $pid) {
|
||
if (@posix_kill($pid, 0)) {
|
||
$result['alive'][] = $pid;
|
||
}
|
||
}
|
||
$result['ok'] = empty($result['alive']);
|
||
return $result;
|
||
}
|
||
|
||
/* ---------- DB 落库(全部防御式) ---------- */
|
||
|
||
protected function logConnect(string $clientId, $username, string $ip): void
|
||
{
|
||
try {
|
||
Db::name('iot_connection')->insert([
|
||
'client_id' => $clientId,
|
||
'username' => $username,
|
||
'ip' => $ip,
|
||
'status' => 1,
|
||
'create_at' => time(),
|
||
'update_at' => time(),
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
$this->log(1, "连接日志写入失败:{$e->getMessage()}");
|
||
}
|
||
}
|
||
|
||
|
||
protected function logClose(string $clientId): void
|
||
{
|
||
try {
|
||
Db::name('iot_connection')
|
||
->where('client_id', $clientId)
|
||
->order('id', 'desc')
|
||
->limit(1)
|
||
->update(['status' => 0, 'update_at' => time()]);
|
||
} catch (\Throwable $e) {
|
||
}
|
||
}
|
||
|
||
|
||
protected function logSubscribe(string $clientId, string $topic, int $qos): void
|
||
{
|
||
try {
|
||
$exists = Db::name('iot_topic')->where('client_id', $clientId)->where('topic', $topic)->find();
|
||
if ($exists) {
|
||
Db::name('iot_topic')->where('id', $exists['id'])->update(['qos' => $qos, 'create_at' => time()]);
|
||
} else {
|
||
Db::name('iot_topic')->insert([
|
||
'client_id' => $clientId,
|
||
'topic' => $topic,
|
||
'qos' => $qos,
|
||
'create_at' => time(),
|
||
]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
}
|
||
}
|
||
|
||
|
||
protected function logUnsubscribe(string $clientId, string $topic): void
|
||
{
|
||
try {
|
||
Db::name('iot_topic')->where('client_id', $clientId)->where('topic', $topic)->delete();
|
||
} catch (\Throwable $e) {
|
||
}
|
||
}
|
||
|
||
|
||
protected function logMessage(string $clientId, string $topic, string $msg, int $qos, int $retain, string $source = 'client'): void
|
||
{
|
||
$lvl = (int) ($this->config['log_level'] ?? '1');
|
||
if ($lvl < 2) {
|
||
return;
|
||
}
|
||
try {
|
||
Db::name('iot_message')->insert([
|
||
'client_id' => $clientId,
|
||
'topic' => $topic,
|
||
'payload' => $msg,
|
||
'qos' => $qos,
|
||
'retain' => $retain ? 1 : 0,
|
||
'source' => $source,
|
||
'create_at' => time(),
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
}
|
||
}
|
||
}
|