1235 lines
46 KiB
PHP
1235 lines
46 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\mqttbroker\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\mqttbroker\protocol\Mqtt;
|
||
use addon\mqttbroker\service\Bridge;
|
||
use addon\mqttbroker\service\Forwarder;
|
||
use addon\mqttbroker\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): void
|
||
{
|
||
$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'];
|
||
}
|
||
|
||
// 主 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
|
||
}
|
||
$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)
|
||
// 注意:Workerman 在 Windows 下不支持“单文件多监听”,故仅在非 Windows(生产 Linux) 启用。
|
||
$wsPort = (int) ($this->config['ws_port'] ?? 0);
|
||
$isWindows = DIRECTORY_SEPARATOR === '\\';
|
||
if ($wsPort > 0 && !$isWindows) {
|
||
$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'];
|
||
}
|
||
|
||
$tip = "MQTT Broker 启动于 {$host}:{$port}({$scheme}, MQTT 3.1.1/5.0)";
|
||
if ($wsPort > 0 && !$isWindows) {
|
||
$tip .= ",WebSocket 于 {$host}:{$wsPort}";
|
||
} elseif ($wsPort > 0 && $isWindows) {
|
||
$tip .= "(Windows 单进程调试:WebSocket 未启用,生产 Linux 下自动启用)";
|
||
}
|
||
$this->log(2, $tip);
|
||
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 无报文则断开
|
||
Timer::add(5, function () use ($worker) {
|
||
$now = time();
|
||
foreach ($worker->connections as $conn) {
|
||
if (!empty($conn->mqttConnected) && !empty($conn->mqttKeepAlive)) {
|
||
if ($now - ($conn->mqttLastTime ?? $now) > $conn->mqttKeepAlive * 1.5) {
|
||
$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']);
|
||
}
|
||
|
||
// 实时指标快照:周期写入 DB(后台仪表盘跨进程读取),仅主进程
|
||
if ($this->isPrimary) {
|
||
$this->persistStats();
|
||
Timer::add(2, [$this, 'persistStats']);
|
||
// 手动发布出站队列消费(跨进程内部路由,零外部依赖)
|
||
Timer::add(0.5, [$this, 'consumeOutbox']);
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* 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);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 统一发送: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,
|
||
];
|
||
|
||
$connection->mqttConnected = true;
|
||
$connection->clientId = $clientId;
|
||
$connection->mqttUsername = $username;
|
||
$connection->mqttSuperuser = !empty($authRes['superuser']);
|
||
$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());
|
||
|
||
// 会话恢复:补投离线消息 + 已存订阅的保留消息
|
||
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))) {
|
||
// 拒绝: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, "发布被 ACL 拒绝 client={$connection->clientId} topic={$topic}");
|
||
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,
|
||
]);
|
||
}
|
||
}
|
||
|
||
|
||
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,
|
||
]);
|
||
}
|
||
}
|
||
$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))) {
|
||
$granted[] = ($connection->mqttProto ?? 4) === 5 ? 0x87 : 0x80; // 拒绝
|
||
$this->log(1, "订阅被 ACL 拒绝 client={$cid} topic={$rawFilter}");
|
||
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 = [];
|
||
|
||
foreach ($this->sessions as $cid => $sess) {
|
||
$conn = $sess['conn'] ?? null;
|
||
foreach ($sess['subscriptions'] as $sub) {
|
||
if (!$this->topicMatch($sub['filter'], $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 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)) {
|
||
$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) {
|
||
$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
|
||
{
|
||
$online = 0;
|
||
foreach ($this->sessions as $sess) {
|
||
if (!empty($sess['conn'])) {
|
||
$online++;
|
||
}
|
||
}
|
||
$uptime = time() - ($this->stats['startTime'] ?: time());
|
||
$metrics = [
|
||
'$SYS/broker/uptime' => (string) $uptime,
|
||
'$SYS/broker/clients/connected' => (string) $online,
|
||
'$SYS/broker/clients/total' => (string) count($this->sessions),
|
||
'$SYS/broker/subscriptions/count' => (string) $this->countSubscriptions(),
|
||
'$SYS/broker/messages/received' => (string) $this->stats['recv'],
|
||
'$SYS/broker/messages/sent' => (string) $this->stats['sent'],
|
||
'$SYS/broker/retained/count' => (string) count($this->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_mqttbroker_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) {
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将当前运行指标写入 DB(单行)。供后台实时监控仪表盘轮询读取。
|
||
*/
|
||
public function persistStats(): void
|
||
{
|
||
$online = 0;
|
||
foreach ($this->sessions as $sess) {
|
||
if (!empty($sess['conn'])) {
|
||
$online++;
|
||
}
|
||
}
|
||
$data = [
|
||
'uptime' => time() - ($this->stats['startTime'] ?: time()),
|
||
'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),
|
||
'timestamp' => time(),
|
||
];
|
||
try {
|
||
$row = Db::name('mqttbroker_stats')->where('id', 1)->find();
|
||
if ($row) {
|
||
Db::name('mqttbroker_stats')->where('id', 1)->update($data);
|
||
} else {
|
||
Db::name('mqttbroker_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('mqttbroker');
|
||
} catch (\Throwable $e) {
|
||
$saved = [];
|
||
}
|
||
$defaults = [
|
||
'port' => 1883,
|
||
'host' => '0.0.0.0',
|
||
'ws_port' => 8083,
|
||
'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' => 'mqttbroker',
|
||
'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("[mqttbroker] {$msg}");
|
||
}
|
||
|
||
/* ---------- DB 落库(全部防御式) ---------- */
|
||
|
||
protected function logConnect(string $clientId, $username, string $ip): void
|
||
{
|
||
try {
|
||
Db::name('mqttbroker_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('mqttbroker_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('mqttbroker_topic')->where('client_id', $clientId)->where('topic', $topic)->find();
|
||
if ($exists) {
|
||
Db::name('mqttbroker_topic')->where('id', $exists['id'])->update(['qos' => $qos, 'create_at' => time()]);
|
||
} else {
|
||
Db::name('mqttbroker_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('mqttbroker_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('mqttbroker_message')->insert([
|
||
'client_id' => $clientId,
|
||
'topic' => $topic,
|
||
'payload' => $msg,
|
||
'qos' => $qos,
|
||
'retain' => $retain ? 1 : 0,
|
||
'source' => $source,
|
||
'create_at' => time(),
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
}
|
||
}
|
||
}
|