mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 23:23:55 +08:00
mqtt gateway project
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Xiaoxia
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,215 @@
|
||||
# MQTT+UDP 到 WebSocket 桥接服务
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个用于物联网设备通信的桥接服务,实现了MQTT和UDP协议到WebSocket的转换。该服务允许设备通过MQTT协议进行控制消息传输,同时通过UDP协议高效传输音频数据,并将这些数据桥接到WebSocket服务。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **多协议支持**: 同时支持MQTT、UDP和WebSocket协议
|
||||
- **音频数据传输**: 专为音频数据流优化的传输机制
|
||||
- **加密通信**: 使用AES-128-CTR加密UDP数据传输
|
||||
- **会话管理**: 完整的设备会话生命周期管理
|
||||
- **自动重连**: 连接断开时自动重连机制
|
||||
- **心跳检测**: 定期检查连接活跃状态
|
||||
- **开发/生产环境配置**: 支持不同环境的配置切换
|
||||
|
||||
## 技术架构
|
||||
|
||||
- **MQTT服务器**: 处理设备控制消息
|
||||
- **UDP服务器**: 处理高效的音频数据传输
|
||||
- **WebSocket客户端**: 连接到聊天服务器
|
||||
- **桥接层**: 在不同协议间转换和路由消息
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── app.js # 主应用入口
|
||||
├── mqtt-protocol.js # MQTT协议实现
|
||||
├── ecosystem.config.js # PM2配置文件
|
||||
├── package.json # 项目依赖
|
||||
├── .env # 环境变量配置
|
||||
├── utils/
|
||||
│ ├── config-manager.js # 配置管理工具
|
||||
│ ├── mqtt_config_v2.js # MQTT配置验证工具
|
||||
│ └── weixinAlert.js # 微信告警工具
|
||||
└── config/ # 配置文件目录
|
||||
```
|
||||
|
||||
## 依赖项
|
||||
|
||||
- **debug**: 调试日志输出
|
||||
- **dotenv**: 环境变量管理
|
||||
- **ws**: WebSocket客户端
|
||||
- **events**: Node.js 事件模块
|
||||
|
||||
## 安装要求
|
||||
|
||||
- Node.js 14.x 或更高版本
|
||||
- npm 或 yarn 包管理器
|
||||
- PM2 (用于生产环境部署)
|
||||
|
||||
## 安装步骤
|
||||
|
||||
1. 克隆仓库
|
||||
```bash
|
||||
git clone <仓库地址>
|
||||
cd mqtt-websocket-bridge
|
||||
```
|
||||
|
||||
2. 安装依赖
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. 创建配置文件
|
||||
```bash
|
||||
mkdir -p config
|
||||
cp config/mqtt.json.example config/mqtt.json
|
||||
```
|
||||
|
||||
4. 编辑配置文件 `config/mqtt.json`,设置适当的参数
|
||||
|
||||
## 配置说明
|
||||
|
||||
配置文件 `config/mqtt.json` 需要包含以下内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"debug": false,
|
||||
"development": {
|
||||
"mac_addresss": ["aa:bb:cc:dd:ee:ff"],
|
||||
"chat_servers": ["wss://dev-chat-server.example.com/ws"]
|
||||
},
|
||||
"production": {
|
||||
"chat_servers": ["wss://chat-server.example.com/ws"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
创建 `.env` 文件并设置以下环境变量:
|
||||
|
||||
```
|
||||
MQTT_PORT=1883 # MQTT服务器端口
|
||||
UDP_PORT=8884 # UDP服务器端口
|
||||
PUBLIC_IP=your-ip # 服务器公网IP
|
||||
```
|
||||
|
||||
## 运行服务
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
# 直接运行
|
||||
node app.js
|
||||
|
||||
# 调试模式运行
|
||||
DEBUG=mqtt-server node app.js
|
||||
```
|
||||
|
||||
### 生产环境 (使用PM2)
|
||||
|
||||
```bash
|
||||
# 安装PM2
|
||||
npm install -g pm2
|
||||
|
||||
# 启动服务
|
||||
pm2 start ecosystem.config.js
|
||||
|
||||
# 查看日志
|
||||
pm2 logs xz-mqtt
|
||||
|
||||
# 监控服务
|
||||
pm2 monit
|
||||
```
|
||||
|
||||
服务将在以下端口启动:
|
||||
- MQTT 服务器: 端口 1883 (可通过环境变量修改)
|
||||
- UDP 服务器: 端口 8884 (可通过环境变量修改)
|
||||
|
||||
## 协议说明
|
||||
|
||||
### 设备连接流程
|
||||
|
||||
1. 设备通过MQTT协议连接到服务器
|
||||
2. 设备发送 `hello` 消息,包含音频参数和特性
|
||||
3. 服务器创建WebSocket连接到聊天服务器
|
||||
4. 服务器返回UDP连接参数给设备
|
||||
5. 设备通过UDP发送音频数据
|
||||
6. 服务器将音频数据转发到WebSocket
|
||||
7. WebSocket返回的控制消息通过MQTT发送给设备
|
||||
|
||||
### 消息格式
|
||||
|
||||
#### Hello 消息 (设备 -> 服务器)
|
||||
```json
|
||||
{
|
||||
"type": "hello",
|
||||
"version": 3,
|
||||
"audio_params": { ... },
|
||||
"features": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Hello 响应 (服务器 -> 设备)
|
||||
```json
|
||||
{
|
||||
"type": "hello",
|
||||
"version": 3,
|
||||
"session_id": "uuid",
|
||||
"transport": "udp",
|
||||
"udp": {
|
||||
"server": "server-ip",
|
||||
"port": 8884,
|
||||
"encryption": "aes-128-ctr",
|
||||
"key": "hex-encoded-key",
|
||||
"nonce": "hex-encoded-nonce"
|
||||
},
|
||||
"audio_params": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
- UDP通信使用AES-128-CTR加密
|
||||
- 每个会话使用唯一的加密密钥
|
||||
- 使用序列号防止重放攻击
|
||||
- 设备通过MAC地址进行身份验证
|
||||
- 支持设备分组和UUID验证
|
||||
|
||||
## 性能优化
|
||||
|
||||
- 使用预分配的缓冲区减少内存分配
|
||||
- UDP协议用于高效传输音频数据
|
||||
- 定期清理不活跃的连接
|
||||
- 连接数和活跃连接数监控
|
||||
- 支持多聊天服务器负载均衡
|
||||
|
||||
## 故障排除
|
||||
|
||||
- 检查设备MAC地址格式是否正确
|
||||
- 确保UDP端口在防火墙中开放
|
||||
- 启用调试模式查看详细日志
|
||||
- 检查配置文件中的聊天服务器地址是否正确
|
||||
- 验证设备认证信息是否正确
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新功能
|
||||
|
||||
1. 修改 `mqtt-protocol.js` 以支持新的MQTT功能
|
||||
2. 在 `MQTTConnection` 类中添加新的消息处理方法
|
||||
3. 更新配置管理器以支持新的配置选项
|
||||
4. 在 `WebSocketBridge` 类中添加新的WebSocket处理逻辑
|
||||
|
||||
### 调试技巧
|
||||
|
||||
```bash
|
||||
# 启用所有调试输出
|
||||
DEBUG=* node app.js
|
||||
|
||||
# 只启用MQTT服务器调试
|
||||
DEBUG=mqtt-server node app.js
|
||||
```
|
||||
@@ -0,0 +1,623 @@
|
||||
// Description: MQTT+UDP 到 WebSocket 的桥接
|
||||
// Author: terrence@tenclass.com
|
||||
// Date: 2025-03-12
|
||||
|
||||
require('dotenv').config();
|
||||
const net = require('net');
|
||||
const debugModule = require('debug');
|
||||
const debug = debugModule('mqtt-server');
|
||||
const crypto = require('crypto');
|
||||
const dgram = require('dgram');
|
||||
const Emitter = require('events');
|
||||
const WebSocket = require('ws');
|
||||
const { MQTTProtocol } = require('./mqtt-protocol');
|
||||
const { ConfigManager } = require('./utils/config-manager');
|
||||
const { validateMqttCredentials } = require('./utils/mqtt_config_v2');
|
||||
|
||||
|
||||
function setDebugEnabled(enabled) {
|
||||
if (enabled) {
|
||||
debugModule.enable('mqtt-server');
|
||||
} else {
|
||||
debugModule.disable();
|
||||
}
|
||||
}
|
||||
|
||||
const configManager = new ConfigManager('mqtt.json');
|
||||
configManager.on('configChanged', (config) => {
|
||||
setDebugEnabled(config.debug);
|
||||
});
|
||||
|
||||
setDebugEnabled(configManager.get('debug'));
|
||||
|
||||
class WebSocketBridge extends Emitter {
|
||||
constructor(connection, protocolVersion, macAddress, uuid, userData) {
|
||||
super();
|
||||
this.connection = connection;
|
||||
this.macAddress = macAddress;
|
||||
this.uuid = uuid;
|
||||
this.userData = userData;
|
||||
this.wsClient = null;
|
||||
this.protocolVersion = protocolVersion;
|
||||
this.deviceSaidGoodbye = false;
|
||||
this.initializeChatServer();
|
||||
}
|
||||
|
||||
initializeChatServer() {
|
||||
const devMacAddresss = configManager.get('development')?.mac_addresss || [];
|
||||
let chatServers;
|
||||
if (devMacAddresss.includes(this.macAddress)) {
|
||||
chatServers = configManager.get('development')?.chat_servers;
|
||||
} else {
|
||||
chatServers = configManager.get('production')?.chat_servers;
|
||||
}
|
||||
if (!chatServers) {
|
||||
throw new Error(`未找到 ${this.macAddress} 的聊天服务器`);
|
||||
}
|
||||
this.chatServer = chatServers[Math.floor(Math.random() * chatServers.length)];
|
||||
}
|
||||
|
||||
async connect(audio_params, features) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = {
|
||||
'device-id': this.macAddress,
|
||||
'protocol-version': '2',
|
||||
'authorization': `Bearer test-token`
|
||||
};
|
||||
if (this.uuid) {
|
||||
headers['client-id'] = this.uuid;
|
||||
}
|
||||
if (this.userData && this.userData.ip) {
|
||||
headers['x-forwarded-for'] = this.userData.ip;
|
||||
}
|
||||
this.wsClient = new WebSocket(this.chatServer, { headers });
|
||||
|
||||
this.wsClient.on('open', () => {
|
||||
this.sendJson({
|
||||
type: 'hello',
|
||||
version: 2,
|
||||
transport: 'websocket',
|
||||
audio_params,
|
||||
features
|
||||
});
|
||||
});
|
||||
|
||||
this.wsClient.on('message', (data, isBinary) => {
|
||||
if (isBinary) {
|
||||
const timestamp = data.readUInt32BE(8);
|
||||
const opusLength = data.readUInt32BE(12);
|
||||
const opus = data.subarray(16, 16 + opusLength);
|
||||
// 二进制数据通过UDP发送
|
||||
this.connection.sendUdpMessage(opus, timestamp);
|
||||
} else {
|
||||
// JSON数据通过MQTT发送
|
||||
const message = JSON.parse(data.toString());
|
||||
if (message.type === 'hello') {
|
||||
resolve(message);
|
||||
} else {
|
||||
this.connection.sendMqttMessage(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.wsClient.on('error', (error) => {
|
||||
console.error(`WebSocket error for device ${this.macAddress}:`, error);
|
||||
this.emit('close');
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.wsClient.on('close', () => {
|
||||
this.emit('close');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
sendJson(message) {
|
||||
if (this.wsClient && this.wsClient.readyState === WebSocket.OPEN) {
|
||||
this.wsClient.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
sendAudio(opus, timestamp) {
|
||||
if (this.wsClient && this.wsClient.readyState === WebSocket.OPEN) {
|
||||
const buffer = Buffer.alloc(16 + opus.length);
|
||||
buffer.writeUInt32BE(timestamp, 8);
|
||||
buffer.writeUInt32BE(opus.length, 12);
|
||||
buffer.set(opus, 16);
|
||||
this.wsClient.send(buffer, { binary: true });
|
||||
}
|
||||
}
|
||||
|
||||
isAlive() {
|
||||
return this.wsClient && this.wsClient.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.wsClient) {
|
||||
this.wsClient.close();
|
||||
this.wsClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MacAddressRegex = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/;
|
||||
|
||||
/**
|
||||
* MQTT连接类
|
||||
* 负责应用层逻辑处理
|
||||
*/
|
||||
class MQTTConnection {
|
||||
constructor(socket, connectionId, server) {
|
||||
this.server = server;
|
||||
this.connectionId = connectionId;
|
||||
this.clientId = null;
|
||||
this.username = null;
|
||||
this.password = null;
|
||||
this.bridge = null;
|
||||
this.udp = {
|
||||
remoteAddress: null,
|
||||
cookie: null,
|
||||
localSequence: 0,
|
||||
remoteSequence: 0
|
||||
};
|
||||
this.headerBuffer = Buffer.alloc(16);
|
||||
|
||||
// 创建协议处理器,并传入socket
|
||||
this.protocol = new MQTTProtocol(socket);
|
||||
|
||||
this.setupProtocolHandlers();
|
||||
}
|
||||
|
||||
setupProtocolHandlers() {
|
||||
// 设置协议事件处理
|
||||
this.protocol.on('connect', (connectData) => {
|
||||
this.handleConnect(connectData);
|
||||
});
|
||||
|
||||
this.protocol.on('publish', (publishData) => {
|
||||
this.handlePublish(publishData);
|
||||
});
|
||||
|
||||
this.protocol.on('subscribe', (subscribeData) => {
|
||||
this.handleSubscribe(subscribeData);
|
||||
});
|
||||
|
||||
this.protocol.on('disconnect', () => {
|
||||
this.handleDisconnect();
|
||||
});
|
||||
|
||||
this.protocol.on('close', () => {
|
||||
debug(`${this.clientId} 客户端断开连接`);
|
||||
this.server.removeConnection(this);
|
||||
});
|
||||
|
||||
this.protocol.on('error', (err) => {
|
||||
debug(`${this.clientId} 连接错误:`, err);
|
||||
this.close();
|
||||
});
|
||||
|
||||
this.protocol.on('protocolError', (err) => {
|
||||
debug(`${this.clientId} 协议错误:`, err);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
handleConnect(connectData) {
|
||||
this.clientId = connectData.clientId;
|
||||
this.username = connectData.username;
|
||||
this.password = connectData.password;
|
||||
|
||||
debug('客户端连接:', {
|
||||
clientId: this.clientId,
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
protocol: connectData.protocol,
|
||||
protocolLevel: connectData.protocolLevel,
|
||||
keepAlive: connectData.keepAlive
|
||||
});
|
||||
|
||||
const parts = this.clientId.split('@@@');
|
||||
if (parts.length === 3) { // GID_test@@@mac_address@@@uuid
|
||||
try {
|
||||
const validated = validateMqttCredentials(this.clientId, this.username, this.password);
|
||||
this.groupId = validated.groupId;
|
||||
this.macAddress = validated.macAddress;
|
||||
this.uuid = validated.uuid;
|
||||
this.userData = validated.userData;
|
||||
} catch (error) {
|
||||
debug('MQTT凭据验证失败:', error.message);
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
} else if (parts.length === 2) { // GID_test@@@mac_address
|
||||
this.groupId = parts[0];
|
||||
this.macAddress = parts[1].replace(/_/g, ':');
|
||||
if (!MacAddressRegex.test(this.macAddress)) {
|
||||
debug('无效的 macAddress:', this.macAddress);
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
debug('无效的 clientId:', this.clientId);
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.replyTo = `devices/p2p/${parts[1]}`;
|
||||
|
||||
this.server.addConnection(this);
|
||||
}
|
||||
|
||||
handleSubscribe(subscribeData) {
|
||||
debug('客户端订阅主题:', {
|
||||
clientId: this.clientId,
|
||||
topic: subscribeData.topic,
|
||||
packetId: subscribeData.packetId
|
||||
});
|
||||
|
||||
// 发送 SUBACK
|
||||
this.protocol.sendSuback(subscribeData.packetId, 0);
|
||||
}
|
||||
|
||||
handleDisconnect() {
|
||||
debug('收到断开连接请求:', { clientId: this.clientId });
|
||||
// 清理连接
|
||||
this.server.removeConnection(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closing = true;
|
||||
if (this.bridge) {
|
||||
this.bridge.close();
|
||||
this.bridge = null;
|
||||
} else {
|
||||
this.protocol.close();
|
||||
}
|
||||
}
|
||||
|
||||
checkKeepAlive() {
|
||||
const now = Date.now();
|
||||
const keepAliveInterval = this.protocol.getKeepAliveInterval();
|
||||
|
||||
// 如果keepAliveInterval为0,表示不需要心跳检查
|
||||
if (keepAliveInterval === 0 || !this.protocol.isConnected) return;
|
||||
|
||||
const lastActivity = this.protocol.getLastActivity();
|
||||
const timeSinceLastActivity = now - lastActivity;
|
||||
|
||||
// 如果超过心跳间隔,关闭连接
|
||||
if (timeSinceLastActivity > keepAliveInterval) {
|
||||
debug('心跳超时,关闭连接:', this.clientId);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
handlePublish(publishData) {
|
||||
debug('收到发布消息:', {
|
||||
clientId: this.clientId,
|
||||
topic: publishData.topic,
|
||||
payload: publishData.payload,
|
||||
qos: publishData.qos
|
||||
});
|
||||
|
||||
if (publishData.qos !== 0) {
|
||||
debug('不支持的 QoS 级别:', publishData.qos, '关闭连接');
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const json = JSON.parse(publishData.payload);
|
||||
if (json.type === 'hello') {
|
||||
if (json.version !== 3) {
|
||||
debug('不支持的协议版本:', json.version, '关闭连接');
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.parseHelloMessage(json).catch(error => {
|
||||
debug('处理 hello 消息失败:', error);
|
||||
this.close();
|
||||
});
|
||||
} else {
|
||||
this.parseOtherMessage(json).catch(error => {
|
||||
debug('处理其他消息失败:', error);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sendMqttMessage(payload) {
|
||||
debug(`发送消息到 ${this.replyTo}: ${payload}`);
|
||||
this.protocol.sendPublish(this.replyTo, payload, 0, false, false);
|
||||
}
|
||||
|
||||
sendUdpMessage(payload, timestamp) {
|
||||
if (!this.udp.remoteAddress) {
|
||||
debug(`设备 ${this.clientId} 未连接,无法发送 UDP 消息`);
|
||||
return;
|
||||
}
|
||||
this.udp.localSequence++;
|
||||
const header = this.generateUdpHeader(payload.length, timestamp, this.udp.localSequence);
|
||||
const cipher = crypto.createCipheriv(this.udp.encryption, this.udp.key, header);
|
||||
const message = Buffer.concat([header, cipher.update(payload), cipher.final()]);
|
||||
this.server.sendUdpMessage(message, this.udp.remoteAddress);
|
||||
}
|
||||
|
||||
generateUdpHeader(length, timestamp, sequence) {
|
||||
// 重用预分配的缓冲区
|
||||
this.headerBuffer.writeUInt8(1, 0);
|
||||
this.headerBuffer.writeUInt16BE(length, 2);
|
||||
this.headerBuffer.writeUInt32BE(this.connectionId, 4);
|
||||
this.headerBuffer.writeUInt32BE(timestamp, 8);
|
||||
this.headerBuffer.writeUInt32BE(sequence, 12);
|
||||
return Buffer.from(this.headerBuffer); // 返回副本以避免并发问题
|
||||
}
|
||||
|
||||
async parseHelloMessage(json) {
|
||||
this.udp = {
|
||||
...this.udp,
|
||||
key: crypto.randomBytes(16),
|
||||
nonce: this.generateUdpHeader(0, 0, 0),
|
||||
encryption: 'aes-128-ctr',
|
||||
remoteSequence: 0,
|
||||
localSequence: 0,
|
||||
startTime: Date.now()
|
||||
}
|
||||
|
||||
if (this.bridge) {
|
||||
debug(`${this.clientId} 收到重复 hello 消息,关闭之前的 bridge`);
|
||||
this.bridge.close();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
this.bridge = new WebSocketBridge(this, json.version, this.macAddress, this.uuid, this.userData);
|
||||
this.bridge.on('close', () => {
|
||||
const seconds = (Date.now() - this.udp.startTime) / 1000;
|
||||
console.log(`通话结束: ${this.clientId} Session: ${this.udp.session_id} Duration: ${seconds}s`);
|
||||
this.sendMqttMessage(JSON.stringify({ type: 'goodbye', session_id: this.udp.session_id }));
|
||||
this.bridge = null;
|
||||
if (this.closing) {
|
||||
this.protocol.close();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(`通话开始: ${this.clientId} Protocol: ${json.version} ${this.bridge.chatServer}`);
|
||||
const helloReply = await this.bridge.connect(json.audio_params, json.features);
|
||||
this.udp.session_id = helloReply.session_id;
|
||||
this.sendMqttMessage(JSON.stringify({
|
||||
type: 'hello',
|
||||
version: json.version,
|
||||
session_id: this.udp.session_id,
|
||||
transport: 'udp',
|
||||
udp: {
|
||||
server: this.server.publicIp,
|
||||
port: this.server.udpPort,
|
||||
encryption: this.udp.encryption,
|
||||
key: this.udp.key.toString('hex'),
|
||||
nonce: this.udp.nonce.toString('hex'),
|
||||
},
|
||||
audio_params: helloReply.audio_params
|
||||
}));
|
||||
} catch (error) {
|
||||
this.sendMqttMessage(JSON.stringify({ type: 'error', message: '处理 hello 消息失败' }));
|
||||
console.error(`${this.clientId} 处理 hello 消息失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async parseOtherMessage(json) {
|
||||
if (!this.bridge) {
|
||||
if (json.type !== 'goodbye') {
|
||||
this.sendMqttMessage(JSON.stringify({ type: 'goodbye', session_id: json.session_id }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.type === 'goodbye') {
|
||||
this.bridge.close();
|
||||
this.bridge = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bridge.sendJson(json);
|
||||
}
|
||||
|
||||
onUdpMessage(rinfo, message, payloadLength, timestamp, sequence) {
|
||||
if (!this.bridge) {
|
||||
return;
|
||||
}
|
||||
if (this.udp.remoteAddress !== rinfo) {
|
||||
this.udp.remoteAddress = rinfo;
|
||||
}
|
||||
if (sequence < this.udp.remoteSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理加密数据
|
||||
const header = message.slice(0, 16);
|
||||
const encryptedPayload = message.slice(16, 16 + payloadLength);
|
||||
const cipher = crypto.createDecipheriv(this.udp.encryption, this.udp.key, header);
|
||||
const payload = Buffer.concat([cipher.update(encryptedPayload), cipher.final()]);
|
||||
|
||||
this.bridge.sendAudio(payload, timestamp);
|
||||
this.udp.remoteSequence = sequence;
|
||||
}
|
||||
|
||||
isAlive() {
|
||||
return this.bridge && this.bridge.isAlive();
|
||||
}
|
||||
}
|
||||
|
||||
class MQTTServer {
|
||||
constructor() {
|
||||
this.mqttPort = parseInt(process.env.MQTT_PORT) || 1883;
|
||||
this.udpPort = parseInt(process.env.UDP_PORT) || this.mqttPort;
|
||||
this.publicIp = process.env.PUBLIC_IP || 'mqtt.xiaozhi.me';
|
||||
this.connections = new Map(); // clientId -> MQTTConnection
|
||||
this.keepAliveTimer = null;
|
||||
this.keepAliveCheckInterval = 1000; // 默认每1秒检查一次
|
||||
|
||||
this.headerBuffer = Buffer.alloc(16);
|
||||
}
|
||||
|
||||
generateNewConnectionId() {
|
||||
// 生成一个32位不重复的整数
|
||||
let id;
|
||||
do {
|
||||
id = Math.floor(Math.random() * 0xFFFFFFFF);
|
||||
} while (this.connections.has(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqttServer = net.createServer((socket) => {
|
||||
const connectionId = this.generateNewConnectionId();
|
||||
debug(`新客户端连接: ${connectionId}`);
|
||||
new MQTTConnection(socket, connectionId, this);
|
||||
});
|
||||
|
||||
this.mqttServer.listen(this.mqttPort, () => {
|
||||
console.warn(`MQTT 服务器正在监听端口 ${this.mqttPort}`);
|
||||
});
|
||||
|
||||
|
||||
this.udpServer = dgram.createSocket('udp4');
|
||||
this.udpServer.on('message', this.onUdpMessage.bind(this));
|
||||
this.udpServer.on('error', err => {
|
||||
console.error('UDP 错误', err);
|
||||
setTimeout(() => { process.exit(1); }, 1000);
|
||||
});
|
||||
this.udpServer.bind(this.udpPort, () => {
|
||||
console.warn(`UDP 服务器正在监听 ${this.publicIp}:${this.udpPort}`);
|
||||
});
|
||||
|
||||
// 启动全局心跳检查定时器
|
||||
this.setupKeepAliveTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局心跳检查定时器
|
||||
*/
|
||||
setupKeepAliveTimer() {
|
||||
// 清除现有定时器
|
||||
this.clearKeepAliveTimer();
|
||||
this.lastConnectionCount = 0;
|
||||
this.lastActiveConnectionCount = 0;
|
||||
|
||||
// 设置新的定时器
|
||||
this.keepAliveTimer = setInterval(() => {
|
||||
// 检查所有连接的心跳状态
|
||||
for (const connection of this.connections.values()) {
|
||||
connection.checkKeepAlive();
|
||||
}
|
||||
|
||||
const activeCount = Array.from(this.connections.values()).filter(connection => connection.isAlive()).length;
|
||||
if (activeCount !== this.lastActiveConnectionCount || this.connections.size !== this.lastConnectionCount) {
|
||||
console.log(`连接数: ${this.connections.size}, 活跃数: ${activeCount}`);
|
||||
this.lastActiveConnectionCount = activeCount;
|
||||
this.lastConnectionCount = this.connections.size;
|
||||
}
|
||||
}, this.keepAliveCheckInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除心跳检查定时器
|
||||
*/
|
||||
clearKeepAliveTimer() {
|
||||
if (this.keepAliveTimer) {
|
||||
clearInterval(this.keepAliveTimer);
|
||||
this.keepAliveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
addConnection(connection) {
|
||||
// 检查是否已存在相同 clientId 的连接
|
||||
for (const [key, value] of this.connections.entries()) {
|
||||
if (value.clientId === connection.clientId) {
|
||||
debug(`${connection.clientId} 已存在连接,关闭旧连接`);
|
||||
value.close();
|
||||
}
|
||||
}
|
||||
this.connections.set(connection.connectionId, connection);
|
||||
}
|
||||
|
||||
removeConnection(connection) {
|
||||
debug(`关闭连接: ${connection.connectionId}`);
|
||||
if (this.connections.has(connection.connectionId)) {
|
||||
this.connections.delete(connection.connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
sendUdpMessage(message, remoteAddress) {
|
||||
this.udpServer.send(message, remoteAddress.port, remoteAddress.address);
|
||||
}
|
||||
|
||||
onUdpMessage(message, rinfo) {
|
||||
// message format: [type: 1u, flag: 1u, payloadLength: 2u, cookie: 4u, timestamp: 4u, sequence: 4u, payload: n]
|
||||
if (message.length < 16) {
|
||||
console.warn('收到不完整的 UDP Header', rinfo);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const type = message.readUInt8(0);
|
||||
if (type !== 1) return;
|
||||
|
||||
const payloadLength = message.readUInt16BE(2);
|
||||
if (message.length < 16 + payloadLength) return;
|
||||
|
||||
const connectionId = message.readUInt32BE(4);
|
||||
const connection = this.connections.get(connectionId);
|
||||
if (!connection) return;
|
||||
|
||||
const timestamp = message.readUInt32BE(8);
|
||||
const sequence = message.readUInt32BE(12);
|
||||
|
||||
connection.onUdpMessage(rinfo, message, payloadLength, timestamp, sequence);
|
||||
} catch (error) {
|
||||
console.error('UDP 消息处理错误:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止服务器
|
||||
*/
|
||||
async stop() {
|
||||
if (this.stopping) {
|
||||
return;
|
||||
}
|
||||
this.stopping = true;
|
||||
// 清除心跳检查定时器
|
||||
this.clearKeepAliveTimer();
|
||||
|
||||
if (this.connections.size > 0) {
|
||||
console.warn(`等待 ${this.connections.size} 个连接关闭`);
|
||||
for (const connection of this.connections.values()) {
|
||||
connection.close();
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
debug('等待连接关闭完成');
|
||||
this.connections.clear();
|
||||
}
|
||||
|
||||
if (this.udpServer) {
|
||||
this.udpServer.close();
|
||||
this.udpServer = null;
|
||||
console.warn('UDP 服务器已停止');
|
||||
}
|
||||
|
||||
// 关闭MQTT服务器
|
||||
if (this.mqttServer) {
|
||||
this.mqttServer.close();
|
||||
this.mqttServer = null;
|
||||
console.warn('MQTT 服务器已停止');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建并启动服务器
|
||||
const server = new MQTTServer();
|
||||
server.start();
|
||||
process.on('SIGINT', () => {
|
||||
console.warn('收到 SIGINT 信号,开始关闭');
|
||||
server.stop();
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"production": {
|
||||
"chat_servers": [
|
||||
"ws://example:8080"
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"chat_servers": [
|
||||
"ws://example:8180"
|
||||
],
|
||||
"mac_addresss": [
|
||||
]
|
||||
},
|
||||
"debug": false
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"production": {
|
||||
"chat_servers": [
|
||||
"ws://192.168.68.66:8000/xiaozhi/v1/"
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"chat_servers": ["ws://192.168.68.66:8000/xiaozhi/v1/"],
|
||||
"mac_addresss": ["d8:43:ae:3e:4b:5a"]
|
||||
},
|
||||
"debug": false
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
"apps": [
|
||||
{
|
||||
"name": "xz-mqtt",
|
||||
"script": "app.js",
|
||||
"time": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* MQTT签名密钥生成器
|
||||
* 用于生成MQTT_SIGNATURE_KEY环境变量
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function generateSecureKey(length = 32) {
|
||||
// 生成随机字节
|
||||
const randomBytes = crypto.randomBytes(length);
|
||||
|
||||
// 转换为base64字符串
|
||||
const base64Key = randomBytes.toString('base64');
|
||||
|
||||
return base64Key;
|
||||
}
|
||||
|
||||
function generateHexKey(length = 32) {
|
||||
// 生成随机字节
|
||||
const randomBytes = crypto.randomBytes(length);
|
||||
|
||||
// 转换为十六进制字符串
|
||||
const hexKey = randomBytes.toString('hex');
|
||||
|
||||
return hexKey;
|
||||
}
|
||||
|
||||
function generateUUIDKey() {
|
||||
// 使用UUID v4作为密钥
|
||||
const uuid = crypto.randomUUID();
|
||||
return uuid;
|
||||
}
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('MQTT签名密钥生成器');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log('\n1. Base64格式密钥 (推荐):');
|
||||
const base64Key = generateSecureKey();
|
||||
console.log(` ${base64Key}`);
|
||||
|
||||
console.log('\n2. 十六进制格式密钥:');
|
||||
const hexKey = generateHexKey();
|
||||
console.log(` ${hexKey}`);
|
||||
|
||||
console.log('\n3. UUID格式密钥:');
|
||||
const uuidKey = generateUUIDKey();
|
||||
console.log(` ${uuidKey}`);
|
||||
|
||||
console.log('\n='.repeat(60));
|
||||
console.log('使用方法:');
|
||||
console.log('='.repeat(60));
|
||||
console.log('\n在Windows PowerShell中设置环境变量:');
|
||||
console.log(`$env:MQTT_SIGNATURE_KEY="${base64Key}"`);
|
||||
|
||||
console.log('\n在Windows CMD中设置环境变量:');
|
||||
console.log(`set MQTT_SIGNATURE_KEY=${base64Key}`);
|
||||
|
||||
console.log('\n在Linux/macOS中设置环境变量:');
|
||||
console.log(`export MQTT_SIGNATURE_KEY="${base64Key}"`);
|
||||
|
||||
console.log('\n在.env文件中设置:');
|
||||
console.log(`MQTT_SIGNATURE_KEY=${base64Key}`);
|
||||
|
||||
console.log('\n='.repeat(60));
|
||||
console.log('注意事项:');
|
||||
console.log('='.repeat(60));
|
||||
console.log('1. 请妥善保管生成的密钥,不要泄露给他人');
|
||||
console.log('2. 在生产环境中,建议使用更长的密钥 (64字节)');
|
||||
console.log('3. 密钥设置后需要重启MQTT服务器才能生效');
|
||||
console.log('4. 客户端连接时需要使用相同的密钥生成密码签名');
|
||||
|
||||
// 如果提供了命令行参数,生成指定长度的密钥
|
||||
if (process.argv[2]) {
|
||||
const customLength = parseInt(process.argv[2]);
|
||||
if (customLength > 0) {
|
||||
console.log(`\n自定义长度密钥 (${customLength}字节):`);
|
||||
console.log(` ${generateSecureKey(customLength)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
const debug = require('debug')('mqtt-server');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
// MQTT 固定头部的类型
|
||||
const PacketType = {
|
||||
CONNECT: 1,
|
||||
CONNACK: 2,
|
||||
PUBLISH: 3,
|
||||
SUBSCRIBE: 8,
|
||||
SUBACK: 9,
|
||||
PINGREQ: 12,
|
||||
PINGRESP: 13,
|
||||
DISCONNECT: 14 // 添加 DISCONNECT
|
||||
};
|
||||
|
||||
/**
|
||||
* MQTT协议处理类
|
||||
* 负责MQTT协议的解析和封装,以及心跳维持
|
||||
*/
|
||||
class MQTTProtocol extends EventEmitter {
|
||||
constructor(socket) {
|
||||
super();
|
||||
this.socket = socket;
|
||||
this.buffer = Buffer.alloc(0);
|
||||
this.isConnected = false;
|
||||
this.keepAliveInterval = 0;
|
||||
this.lastActivity = Date.now();
|
||||
|
||||
this.setupSocketHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置Socket事件处理
|
||||
*/
|
||||
setupSocketHandlers() {
|
||||
this.socket.on('data', (data) => {
|
||||
this.lastActivity = Date.now();
|
||||
this.buffer = Buffer.concat([this.buffer, data]);
|
||||
this.processBuffer();
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
this.emit('close');
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
this.emit('error', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理缓冲区中的所有完整消息
|
||||
*/
|
||||
processBuffer() {
|
||||
// 持续处理缓冲区中的数据,直到没有完整的消息可以处理
|
||||
while (this.buffer.length > 0) {
|
||||
// 至少需要2个字节才能开始解析(1字节固定头部 + 至少1字节的剩余长度)
|
||||
if (this.buffer.length < 2) return;
|
||||
|
||||
try {
|
||||
// 获取消息类型
|
||||
const firstByte = this.buffer[0];
|
||||
const type = (firstByte >> 4);
|
||||
|
||||
// 解析剩余长度
|
||||
const { value: remainingLength, bytesRead } = this.decodeRemainingLength(this.buffer);
|
||||
|
||||
// 计算整个消息的长度
|
||||
const messageLength = 1 + bytesRead + remainingLength;
|
||||
|
||||
// 检查缓冲区中是否有完整的消息
|
||||
if (this.buffer.length < messageLength) {
|
||||
// 消息不完整,等待更多数据
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取完整的消息
|
||||
const message = this.buffer.subarray(0, messageLength);
|
||||
if (!this.isConnected && type !== PacketType.CONNECT) {
|
||||
debug('未连接时收到非CONNECT消息,关闭连接');
|
||||
this.socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据消息类型处理
|
||||
switch (type) {
|
||||
case PacketType.CONNECT:
|
||||
this.parseConnect(message);
|
||||
break;
|
||||
case PacketType.PUBLISH:
|
||||
this.parsePublish(message);
|
||||
break;
|
||||
case PacketType.SUBSCRIBE:
|
||||
this.parseSubscribe(message);
|
||||
break;
|
||||
case PacketType.PINGREQ:
|
||||
this.parsePingReq(message);
|
||||
break;
|
||||
case PacketType.DISCONNECT:
|
||||
this.parseDisconnect(message);
|
||||
break;
|
||||
default:
|
||||
debug('未处理的包类型:', type, message);
|
||||
this.emit('protocolError', new Error(`未处理的包类型: ${type}`));
|
||||
}
|
||||
|
||||
// 从缓冲区中移除已处理的消息
|
||||
this.buffer = this.buffer.subarray(messageLength);
|
||||
|
||||
} catch (err) {
|
||||
// 如果解析出错,可能是数据不完整,等待更多数据
|
||||
if (err.message === 'Malformed Remaining Length') {
|
||||
return;
|
||||
}
|
||||
// 其他错误可能是协议错误,清空缓冲区并发出错误事件
|
||||
this.buffer = Buffer.alloc(0);
|
||||
this.emit('protocolError', err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析MQTT报文中的Remaining Length字段
|
||||
* @param {Buffer} buffer - 消息缓冲区
|
||||
* @returns {{value: number, bytesRead: number}} 包含解析的值和读取的字节数
|
||||
*/
|
||||
decodeRemainingLength(buffer) {
|
||||
let multiplier = 1;
|
||||
let value = 0;
|
||||
let bytesRead = 0;
|
||||
let digit;
|
||||
|
||||
do {
|
||||
if (bytesRead >= 4 || bytesRead >= buffer.length - 1) {
|
||||
throw new Error('Malformed Remaining Length');
|
||||
}
|
||||
|
||||
digit = buffer[bytesRead + 1];
|
||||
bytesRead++;
|
||||
|
||||
value += (digit & 127) * multiplier;
|
||||
multiplier *= 128;
|
||||
|
||||
} while ((digit & 128) !== 0);
|
||||
|
||||
return { value, bytesRead };
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码MQTT报文中的Remaining Length字段
|
||||
* @param {number} length - 要编码的长度值
|
||||
* @returns {{bytes: Buffer, bytesLength: number}} 包含编码后的字节和字节长度
|
||||
*/
|
||||
encodeRemainingLength(length) {
|
||||
let digit;
|
||||
const bytes = Buffer.alloc(4); // 最多4个字节
|
||||
let bytesLength = 0;
|
||||
|
||||
do {
|
||||
digit = length % 128;
|
||||
length = Math.floor(length / 128);
|
||||
// 如果还有更多字节,设置最高位
|
||||
if (length > 0) {
|
||||
digit |= 0x80;
|
||||
}
|
||||
bytes[bytesLength++] = digit;
|
||||
} while (length > 0 && bytesLength < 4);
|
||||
|
||||
return { bytes, bytesLength };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析CONNECT消息
|
||||
* @param {Buffer} message - 完整的CONNECT消息
|
||||
*/
|
||||
parseConnect(message) {
|
||||
// 解析剩余长度
|
||||
const { value: remainingLength, bytesRead } = this.decodeRemainingLength(message);
|
||||
|
||||
// 固定头部之后的位置 (MQTT固定头部第一个字节 + Remaining Length字段的字节)
|
||||
const headerLength = 1 + bytesRead;
|
||||
|
||||
// 从可变头部开始位置读取协议名长度
|
||||
const protocolLength = message.readUInt16BE(headerLength);
|
||||
const protocol = message.toString('utf8', headerLength + 2, headerLength + 2 + protocolLength);
|
||||
|
||||
// 更新位置指针,跳过协议名
|
||||
let pos = headerLength + 2 + protocolLength;
|
||||
|
||||
// 协议级别,4为MQTT 3.1.1
|
||||
const protocolLevel = message[pos];
|
||||
|
||||
// 检查协议版本
|
||||
if (protocolLevel !== 4) { // 4 表示 MQTT 3.1.1
|
||||
debug('不支持的协议版本:', protocolLevel);
|
||||
// 发送 CONNACK,使用不支持的协议版本的返回码 (0x01)
|
||||
this.sendConnack(1, false);
|
||||
// 关闭连接
|
||||
this.socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
pos += 1;
|
||||
|
||||
// 连接标志
|
||||
const connectFlags = message[pos];
|
||||
const hasUsername = (connectFlags & 0x80) !== 0;
|
||||
const hasPassword = (connectFlags & 0x40) !== 0;
|
||||
const cleanSession = (connectFlags & 0x02) !== 0;
|
||||
pos += 1;
|
||||
|
||||
// 保持连接时间
|
||||
const keepAlive = message.readUInt16BE(pos);
|
||||
pos += 2;
|
||||
|
||||
// 解析 clientId
|
||||
const clientIdLength = message.readUInt16BE(pos);
|
||||
pos += 2;
|
||||
const clientId = message.toString('utf8', pos, pos + clientIdLength);
|
||||
pos += clientIdLength;
|
||||
|
||||
// 解析 username(如果存在)
|
||||
let username = '';
|
||||
if (hasUsername) {
|
||||
const usernameLength = message.readUInt16BE(pos);
|
||||
pos += 2;
|
||||
username = message.toString('utf8', pos, pos + usernameLength);
|
||||
pos += usernameLength;
|
||||
}
|
||||
|
||||
// 解析 password(如果存在)
|
||||
let password = '';
|
||||
if (hasPassword) {
|
||||
const passwordLength = message.readUInt16BE(pos);
|
||||
pos += 2;
|
||||
password = message.toString('utf8', pos, pos + passwordLength);
|
||||
pos += passwordLength;
|
||||
}
|
||||
|
||||
// 设置心跳间隔(客户端指定的keepAlive值的1.5倍,单位为秒)
|
||||
this.keepAliveInterval = keepAlive * 1000 * 1.5;
|
||||
|
||||
// 发送 CONNACK
|
||||
this.sendConnack(0, false);
|
||||
|
||||
// 标记为已连接
|
||||
this.isConnected = true;
|
||||
|
||||
// 发出连接事件
|
||||
this.emit('connect', {
|
||||
clientId,
|
||||
protocol,
|
||||
protocolLevel,
|
||||
keepAlive,
|
||||
username,
|
||||
password,
|
||||
cleanSession
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析PUBLISH消息
|
||||
* @param {Buffer} message - 完整的PUBLISH消息
|
||||
*/
|
||||
parsePublish(message) {
|
||||
// 从第一个字节中提取QoS级别(bits 1-2)
|
||||
const firstByte = message[0];
|
||||
const qos = (firstByte & 0x06) >> 1; // 0x06 是二进制 00000110,用于掩码提取QoS位
|
||||
const dup = (firstByte & 0x08) !== 0; // 0x08 是二进制 00001000,用于掩码提取DUP标志
|
||||
const retain = (firstByte & 0x01) !== 0; // 0x01 是二进制 00000001,用于掩码提取RETAIN标志
|
||||
|
||||
// 使用通用方法解析剩余长度
|
||||
const { value: remainingLength, bytesRead } = this.decodeRemainingLength(message);
|
||||
|
||||
// 固定头部之后的位置 (MQTT固定头部第一个字节 + Remaining Length字段的字节)
|
||||
const headerLength = 1 + bytesRead;
|
||||
|
||||
// 解析主题
|
||||
const topicLength = message.readUInt16BE(headerLength);
|
||||
const topic = message.toString('utf8', headerLength + 2, headerLength + 2 + topicLength);
|
||||
|
||||
// 对于QoS > 0,包含消息ID
|
||||
let packetId = null;
|
||||
let payloadStart = headerLength + 2 + topicLength;
|
||||
|
||||
if (qos > 0) {
|
||||
packetId = message.readUInt16BE(payloadStart);
|
||||
payloadStart += 2;
|
||||
}
|
||||
|
||||
// 解析有效载荷
|
||||
const payload = message.slice(payloadStart).toString('utf8');
|
||||
|
||||
// 发出发布事件
|
||||
this.emit('publish', {
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
dup,
|
||||
retain,
|
||||
packetId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析SUBSCRIBE消息
|
||||
* @param {Buffer} message - 完整的SUBSCRIBE消息
|
||||
*/
|
||||
parseSubscribe(message) {
|
||||
const packetId = message.readUInt16BE(2);
|
||||
const topicLength = message.readUInt16BE(4);
|
||||
const topic = message.toString('utf8', 6, 6 + topicLength);
|
||||
const qos = message[6 + topicLength]; // QoS值
|
||||
|
||||
// 发出订阅事件
|
||||
this.emit('subscribe', {
|
||||
packetId,
|
||||
topic,
|
||||
qos
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析PINGREQ消息
|
||||
* @param {Buffer} message - 完整的PINGREQ消息
|
||||
*/
|
||||
parsePingReq(message) {
|
||||
debug('收到心跳请求');
|
||||
|
||||
// 发送 PINGRESP
|
||||
this.sendPingResp();
|
||||
|
||||
debug('已发送心跳响应');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析DISCONNECT消息
|
||||
* @param {Buffer} message - 完整的DISCONNECT消息
|
||||
*/
|
||||
parseDisconnect(message) {
|
||||
// 标记为未连接
|
||||
this.isConnected = false;
|
||||
|
||||
// 发出断开连接事件
|
||||
this.emit('disconnect');
|
||||
|
||||
// 关闭 socket
|
||||
this.socket.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送CONNACK消息
|
||||
* @param {number} returnCode - 返回码
|
||||
* @param {boolean} sessionPresent - 会话存在标志
|
||||
*/
|
||||
sendConnack(returnCode = 0, sessionPresent = false) {
|
||||
if (!this.socket.writable) return;
|
||||
|
||||
const packet = Buffer.from([
|
||||
PacketType.CONNACK << 4,
|
||||
2, // Remaining length
|
||||
sessionPresent ? 1 : 0, // Connect acknowledge flags
|
||||
returnCode // Return code
|
||||
]);
|
||||
|
||||
this.socket.write(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送PUBLISH消息
|
||||
* @param {string} topic - 主题
|
||||
* @param {string} payload - 有效载荷
|
||||
* @param {number} qos - QoS级别
|
||||
* @param {boolean} dup - 重复标志
|
||||
* @param {boolean} retain - 保留标志
|
||||
* @param {number} packetId - 包ID(仅QoS > 0时需要)
|
||||
*/
|
||||
sendPublish(topic, payload, qos = 0, dup = false, retain = false, packetId = null) {
|
||||
if (!this.isConnected || !this.socket.writable) return;
|
||||
|
||||
const topicLength = Buffer.byteLength(topic);
|
||||
const payloadLength = Buffer.byteLength(payload);
|
||||
|
||||
// 计算剩余长度
|
||||
let remainingLength = 2 + topicLength + payloadLength;
|
||||
|
||||
// 如果QoS > 0,需要包含包ID
|
||||
if (qos > 0 && packetId) {
|
||||
remainingLength += 2;
|
||||
}
|
||||
|
||||
// 编码可变长度
|
||||
const { bytes: remainingLengthBytes, bytesLength: remainingLengthSize } = this.encodeRemainingLength(remainingLength);
|
||||
|
||||
// 分配缓冲区:固定头部(1字节) + 可变长度字段 + 剩余长度值
|
||||
const packet = Buffer.alloc(1 + remainingLengthSize + remainingLength);
|
||||
|
||||
// 写入固定头部
|
||||
let firstByte = PacketType.PUBLISH << 4;
|
||||
if (dup) firstByte |= 0x08;
|
||||
if (qos > 0) firstByte |= (qos << 1);
|
||||
if (retain) firstByte |= 0x01;
|
||||
|
||||
packet[0] = firstByte;
|
||||
|
||||
// 写入可变长度字段
|
||||
remainingLengthBytes.copy(packet, 1, 0, remainingLengthSize);
|
||||
|
||||
// 写入主题长度和主题
|
||||
const variableHeaderStart = 1 + remainingLengthSize;
|
||||
packet.writeUInt16BE(topicLength, variableHeaderStart);
|
||||
packet.write(topic, variableHeaderStart + 2);
|
||||
|
||||
// 如果QoS > 0,写入包ID
|
||||
let payloadStart = variableHeaderStart + 2 + topicLength;
|
||||
if (qos > 0 && packetId) {
|
||||
packet.writeUInt16BE(packetId, payloadStart);
|
||||
payloadStart += 2;
|
||||
}
|
||||
|
||||
// 写入有效载荷
|
||||
packet.write(payload, payloadStart);
|
||||
|
||||
this.socket.write(packet);
|
||||
this.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送SUBACK消息
|
||||
* @param {number} packetId - 包ID
|
||||
* @param {number} returnCode - 返回码
|
||||
*/
|
||||
sendSuback(packetId, returnCode = 0) {
|
||||
if (!this.isConnected || !this.socket.writable) return;
|
||||
|
||||
const packet = Buffer.from([
|
||||
PacketType.SUBACK << 4,
|
||||
3, // Remaining length
|
||||
packetId >> 8, // Packet ID MSB
|
||||
packetId & 0xFF, // Packet ID LSB
|
||||
returnCode // Return code
|
||||
]);
|
||||
|
||||
this.socket.write(packet);
|
||||
this.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送PINGRESP消息
|
||||
*/
|
||||
sendPingResp() {
|
||||
if (!this.isConnected || !this.socket.writable) return;
|
||||
|
||||
const packet = Buffer.from([
|
||||
PacketType.PINGRESP << 4, // Fixed header
|
||||
0 // Remaining length
|
||||
]);
|
||||
|
||||
this.socket.write(packet);
|
||||
this.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上次活动时间
|
||||
*/
|
||||
getLastActivity() {
|
||||
return this.lastActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取心跳间隔
|
||||
*/
|
||||
getKeepAliveInterval() {
|
||||
return this.keepAliveInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓冲区
|
||||
*/
|
||||
clearBuffer() {
|
||||
this.buffer = Buffer.alloc(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
close() {
|
||||
if (this.socket.writable) {
|
||||
this.socket.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出 PacketType 和 MQTTProtocol 类
|
||||
module.exports = {
|
||||
PacketType,
|
||||
MQTTProtocol
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class ConfigManager extends EventEmitter {
|
||||
constructor(fileName) {
|
||||
super();
|
||||
this.config = {}; // 移除默认的 apiKeys 配置
|
||||
this.configPath = path.join(__dirname, "..", "config", fileName);
|
||||
this.loadConfig();
|
||||
this.watchConfig();
|
||||
// 添加防抖计时器变量
|
||||
this.watchDebounceTimer = null;
|
||||
}
|
||||
|
||||
loadConfig() {
|
||||
try {
|
||||
const data = fs.readFileSync(this.configPath, 'utf8');
|
||||
const newConfig = JSON.parse(data);
|
||||
|
||||
// 检测配置是否发生变化
|
||||
if (JSON.stringify(this.config) !== JSON.stringify(newConfig)) {
|
||||
console.log('配置已更新', this.configPath);
|
||||
this.config = newConfig;
|
||||
// 发出配置更新事件
|
||||
this.emit('configChanged', this.config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置出错:', error, this.configPath);
|
||||
if (error.code === 'ENOENT') {
|
||||
this.createEmptyConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEmptyConfig() {
|
||||
try {
|
||||
const dir = path.dirname(this.configPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const defaultConfig = {}; // 空配置对象
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(defaultConfig, null, 2));
|
||||
console.log('已创建空配置文件', this.configPath);
|
||||
} catch (error) {
|
||||
console.error('创建空配置文件出错:', error, this.configPath);
|
||||
}
|
||||
}
|
||||
|
||||
watchConfig() {
|
||||
fs.watch(path.dirname(this.configPath), (eventType, filename) => {
|
||||
if (filename === path.basename(this.configPath) && eventType === 'change') {
|
||||
// 清除之前的计时器
|
||||
if (this.watchDebounceTimer) {
|
||||
clearTimeout(this.watchDebounceTimer);
|
||||
}
|
||||
// 设置新的计时器,300ms 后执行
|
||||
this.watchDebounceTimer = setTimeout(() => {
|
||||
this.loadConfig();
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取配置的方法
|
||||
getConfig() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
// 获取特定配置项的方法
|
||||
get(key) {
|
||||
return this.config[key];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ConfigManager
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
require('dotenv').config();
|
||||
const crypto = require('crypto');
|
||||
|
||||
|
||||
function generatePasswordSignature(content, secretKey) {
|
||||
// Create an HMAC object using SHA256 and the secretKey
|
||||
const hmac = crypto.createHmac('sha256', secretKey);
|
||||
|
||||
// Update the HMAC object with the clientId
|
||||
hmac.update(content);
|
||||
|
||||
// Generate the HMAC digest in binary format
|
||||
const binarySignature = hmac.digest();
|
||||
|
||||
// Encode the binary signature to Base64
|
||||
const base64Signature = binarySignature.toString('base64');
|
||||
|
||||
return base64Signature;
|
||||
}
|
||||
|
||||
function validateMqttCredentials(clientId, username, password) {
|
||||
// 验证密码签名
|
||||
const signatureKey = process.env.MQTT_SIGNATURE_KEY;
|
||||
if (signatureKey) {
|
||||
const expectedSignature = generatePasswordSignature(clientId + '|' + username, signatureKey);
|
||||
if (password !== expectedSignature) {
|
||||
throw new Error('密码签名验证失败');
|
||||
}
|
||||
} else {
|
||||
console.warn('缺少MQTT_SIGNATURE_KEY环境变量,跳过密码签名验证');
|
||||
}
|
||||
|
||||
// 验证clientId
|
||||
if (!clientId || typeof clientId !== 'string') {
|
||||
throw new Error('clientId必须是非空字符串');
|
||||
}
|
||||
|
||||
// 验证clientId格式(必须包含@@@分隔符)
|
||||
const clientIdParts = clientId.split('@@@');
|
||||
// 新版本 MQTT 参数
|
||||
if (clientIdParts.length !== 3) {
|
||||
throw new Error('clientId格式错误,必须包含@@@分隔符');
|
||||
}
|
||||
|
||||
// 验证username
|
||||
if (!username || typeof username !== 'string') {
|
||||
throw new Error('username必须是非空字符串');
|
||||
}
|
||||
|
||||
// 尝试解码username(应该是base64编码的JSON)
|
||||
let userData;
|
||||
try {
|
||||
const decodedUsername = Buffer.from(username, 'base64').toString();
|
||||
userData = JSON.parse(decodedUsername);
|
||||
} catch (error) {
|
||||
throw new Error('username不是有效的base64编码JSON');
|
||||
}
|
||||
|
||||
// 解析clientId中的信息
|
||||
const [groupId, macAddress, uuid] = clientIdParts;
|
||||
|
||||
// 如果验证成功,返回解析后的有用信息
|
||||
return {
|
||||
groupId,
|
||||
macAddress: macAddress.replace(/_/g, ':'),
|
||||
uuid,
|
||||
userData
|
||||
};
|
||||
}
|
||||
|
||||
function generateMqttConfig(groupId, macAddress, uuid, userData) {
|
||||
const endpoint = process.env.MQTT_ENDPOINT;
|
||||
const signatureKey = process.env.MQTT_SIGNATURE_KEY;
|
||||
if (!signatureKey) {
|
||||
console.warn('No signature key, skip generating MQTT config');
|
||||
return;
|
||||
}
|
||||
const deviceIdNoColon = macAddress.replace(/:/g, '_');
|
||||
const clientId = `${groupId}@@@${deviceIdNoColon}@@@${uuid}`;
|
||||
const username = Buffer.from(JSON.stringify(userData)).toString('base64');
|
||||
const password = generatePasswordSignature(clientId + '|' + username, signatureKey);
|
||||
return {
|
||||
endpoint,
|
||||
port: 8883,
|
||||
client_id: clientId,
|
||||
username,
|
||||
password,
|
||||
publish_topic: 'device-server',
|
||||
subscribe_topic: 'null' // 旧版本固件不返回此字段会出错
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateMqttConfig,
|
||||
validateMqttCredentials
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const config = generateMqttConfig('GID_test', '11:22:33:44:55:66', '36c98363-3656-43cb-a00f-8bced2391a90', { ip: '222.222.222.222' });
|
||||
console.log('config', config);
|
||||
const credentials = validateMqttCredentials(config.client_id, config.username, config.password);
|
||||
console.log('credentials', credentials);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
# MQTT网关转发功能测试指南
|
||||
|
||||
本文档说明如何测试MQTT网关的转发功能是否配置成功。
|
||||
|
||||
## 快速测试
|
||||
|
||||
### 1. 基础连接测试
|
||||
|
||||
运行快速测试脚本:
|
||||
|
||||
```bash
|
||||
node quick-test.js
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
- ✅ 检查配置文件
|
||||
- ✅ 测试MQTT服务器连接(端口1883)
|
||||
- ✅ 测试WebSocket后端服务连接
|
||||
- ✅ 显示测试结果和建议
|
||||
|
||||
### 2. 完整功能测试
|
||||
|
||||
运行完整测试脚本:
|
||||
|
||||
```bash
|
||||
node test-mqtt.js
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
- 🔗 连接到MQTT服务器
|
||||
- 🔐 使用配置的MAC地址进行认证
|
||||
- 📝 订阅回复主题
|
||||
- 📤 发送hello消息测试WebSocket转发
|
||||
- 📥 等待并显示服务器响应
|
||||
- 🧪 发送其他测试消息
|
||||
|
||||
## 手动测试步骤
|
||||
|
||||
### 1. 启动MQTT网关
|
||||
|
||||
```bash
|
||||
node app.js
|
||||
```
|
||||
|
||||
应该看到类似输出:
|
||||
```
|
||||
MQTT 服务器启动在端口 1883
|
||||
UDP 服务器启动在端口 1883
|
||||
连接数: 0, 活跃数: 0
|
||||
```
|
||||
|
||||
### 2. 检查配置文件
|
||||
|
||||
确认 `config/mqtt.json` 配置正确:
|
||||
|
||||
```json
|
||||
{
|
||||
"production": {
|
||||
"chat_servers": [
|
||||
"ws://192.168.68.66:8000/xiaozhi/v1/"
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"chat_servers": ["ws://192.168.68.66:8000/xiaozhi/v1/"],
|
||||
"mac_addresss": ["d8:43:ae:3e:4b:5a"]
|
||||
},
|
||||
"debug": false
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 使用MQTT客户端工具测试
|
||||
|
||||
#### 使用mosquitto客户端:
|
||||
|
||||
**连接测试:**
|
||||
```bash
|
||||
mosquitto_pub -h localhost -p 1883 -t "test/topic" -m "hello"
|
||||
```
|
||||
|
||||
**订阅测试:**
|
||||
```bash
|
||||
mosquitto_sub -h localhost -p 1883 -t "devices/p2p/d8_43_ae_3e_4b_5a"
|
||||
```
|
||||
|
||||
#### 使用MQTT.fx或其他GUI工具:
|
||||
|
||||
1. **连接设置:**
|
||||
- 服务器:localhost
|
||||
- 端口:1883
|
||||
- 客户端ID:`GID_test@@@d8_43_ae_3e_4b_5a`
|
||||
- 用户名:test_user
|
||||
- 密码:test_password
|
||||
|
||||
2. **发布测试消息:**
|
||||
- 主题:`test/topic`
|
||||
- 消息:
|
||||
```json
|
||||
{
|
||||
"type": "hello",
|
||||
"version": 3,
|
||||
"transport": "mqtt",
|
||||
"audio_params": {
|
||||
"sample_rate": 16000,
|
||||
"channels": 1,
|
||||
"format": "opus"
|
||||
},
|
||||
"features": ["tts", "asr"]
|
||||
}
|
||||
```
|
||||
|
||||
3. **订阅回复主题:**
|
||||
- 主题:`devices/p2p/d8_43_ae_3e_4b_5a`
|
||||
|
||||
## 测试结果判断
|
||||
|
||||
### ✅ 成功指标
|
||||
|
||||
1. **MQTT连接成功:**
|
||||
```
|
||||
✓ 成功连接到MQTT服务器 localhost:1883
|
||||
✓ MQTT连接成功
|
||||
```
|
||||
|
||||
2. **WebSocket转发成功:**
|
||||
```
|
||||
✓ WebSocket服务器连接成功
|
||||
✓ 收到PUBLISH消息
|
||||
```
|
||||
|
||||
3. **服务器日志正常:**
|
||||
```
|
||||
客户端连接: { clientId: 'GID_test@@@d8_43_ae_3e_4b_5a', ... }
|
||||
收到发布消息: { topic: 'test/topic', payload: '...', qos: 0 }
|
||||
```
|
||||
|
||||
### ❌ 常见问题
|
||||
|
||||
1. **MQTT服务器连接失败:**
|
||||
```
|
||||
✗ 连接错误: connect ECONNREFUSED 127.0.0.1:1883
|
||||
```
|
||||
**解决方案:** 确保运行 `node app.js` 启动MQTT网关
|
||||
|
||||
2. **WebSocket连接失败:**
|
||||
```
|
||||
✗ WebSocket服务器连接失败: connect ECONNREFUSED
|
||||
```
|
||||
**解决方案:** 检查WebSocket后端服务是否运行,网络是否可达
|
||||
|
||||
3. **认证失败:**
|
||||
```
|
||||
无效的 clientId: xxx
|
||||
```
|
||||
**解决方案:** 检查客户端ID格式是否为 `GID_test@@@mac_address`
|
||||
|
||||
4. **MAC地址不匹配:**
|
||||
```
|
||||
未找到 xx:xx:xx:xx:xx:xx 的聊天服务器
|
||||
```
|
||||
**解决方案:** 确认MAC地址在配置文件的 `mac_addresss` 列表中
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 1. 开启调试模式
|
||||
|
||||
修改 `config/mqtt.json`:
|
||||
```json
|
||||
{
|
||||
"debug": true
|
||||
}
|
||||
```
|
||||
|
||||
或设置环境变量:
|
||||
```bash
|
||||
DEBUG=mqtt-server node app.js
|
||||
```
|
||||
|
||||
### 2. 查看详细日志
|
||||
|
||||
调试模式下会显示详细的连接和消息处理信息:
|
||||
```
|
||||
mqtt-server 客户端连接: { clientId: '...', username: '...', ... }
|
||||
mqtt-server 收到发布消息: { topic: '...', payload: '...', qos: 0 }
|
||||
mqtt-server 发送消息到 devices/p2p/...: {...}
|
||||
```
|
||||
|
||||
### 3. 网络连通性测试
|
||||
|
||||
**测试WebSocket服务器:**
|
||||
```bash
|
||||
curl -I http://192.168.68.66:8000/xiaozhi/v1/
|
||||
```
|
||||
|
||||
**测试MQTT端口:**
|
||||
```bash
|
||||
telnet localhost 1883
|
||||
```
|
||||
|
||||
## 性能测试
|
||||
|
||||
### 并发连接测试
|
||||
|
||||
可以修改测试脚本创建多个并发连接:
|
||||
|
||||
```javascript
|
||||
// 创建多个测试客户端
|
||||
const clients = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const client = new MQTTTestClient();
|
||||
clients.push(client);
|
||||
// 连接和测试...
|
||||
}
|
||||
```
|
||||
|
||||
### 消息吞吐量测试
|
||||
|
||||
发送大量消息测试转发性能:
|
||||
|
||||
```javascript
|
||||
// 发送100条消息
|
||||
for (let i = 0; i < 100; i++) {
|
||||
client.publish('test/topic', `测试消息 ${i}`);
|
||||
}
|
||||
```
|
||||
|
||||
## 故障排除清单
|
||||
|
||||
- [ ] MQTT网关服务是否运行 (`node app.js`)
|
||||
- [ ] 端口1883是否被占用
|
||||
- [ ] 配置文件格式是否正确
|
||||
- [ ] WebSocket后端服务是否运行
|
||||
- [ ] 网络连接是否正常
|
||||
- [ ] MAC地址是否在配置的白名单中
|
||||
- [ ] 客户端ID格式是否正确
|
||||
- [ ] 防火墙是否阻止连接
|
||||
|
||||
## 总结
|
||||
|
||||
通过以上测试方法,你可以全面验证MQTT网关的转发功能是否配置成功。建议先运行快速测试,如果有问题再进行详细的手动测试和调试。
|
||||
Reference in New Issue
Block a user