一篇一篇写到第九篇,MQTT 这条线已经从“能连上”走到了“能在公网稳定跑”。前面几篇我们把 EMQX 服务端搭了起来,也把客户端的连接认证、Topic 设计捋了一遍,但一直留着一个隐患——服务端和客户端之间基本都在用 1883 端口直连,所有流量明文传输。内网里自己调试没问题,可一旦设备要跨公网通信,用户名、密码、Topic 里的业务数据全部裸奔。这篇就把这个洞补上:给自己的域名申请 SSL 证书,把 MQTT 服务迁到 TLS 加密链路上来。适合刚把 MQTT 服务器搭起来、准备接入公网设备或者正在做产品原型的开发者参考。
1. 公网环境下,明文 MQTT 的代价到底是什么
1.1 裸奔的 1883 端口:数据被完整暴露
很多新手觉得“我就传个温度湿度,谁稀罕看”。这个想法在内网成立,在公网完全不成立。公网链路上任何一个环节——交换机镜像、路由器抓包、ISP 的流量审计设备——都能把 1883 端口的数据包还原成可读的 MQTT 报文。用户名密码是明文的,设备上报的数据是明文的,你对设备下发的控制指令也是明文的。
尤其是做物联网的同学,设备端往往是 4G 模块直连服务器,走的是运营商网络,中间经过的节点比家用宽带的家庭路由器多得多。以常见场景为例:用 EC20 或者 A7670C 这类 4G 模块连接阿里云 MQTT 时,如果不做 TLS,模块发出的 AT 指令里的 MQTT CONNECT 报文里就带着 clientId、username、password,几乎等于把服务器登录凭据直接写在传输层。要抓到一个设备的凭据,只需要在链路上某个位置做流量镜像,然后用 Wireshark 过滤 MQTT 协议即可,成本极低。
1.2 TLS 在 MQTT 里保护了什么
MQTT over TLS 解决的核心问题有三个。
第一是机密性。握手之后所有 Application Message 都经过对称加密,链路上看到的只是一堆无法还原的密文。第二是完整性。TLS 的 MAC 校验能发现任何中间人篡改报文的尝试,设备上报的数据不会在中途被改掉。第三是身份认证。客户端校验证书链可以确认“我连的服务器确实是持有这个域名证书的那台服务器”,防止 DNS 劫持或者 IP 欺骗把你导到攻击者的伪服务器上。
这里要说明一点:TLS 加密的是传输层,MQTT 本身的 Topic、Payload 结构仍然是明文格式,只是外面裹了一层加密外壳。所以不要在 Topic 里带敏感信息这件事,和上 TLS 不冲突,两者是不同层面的防护。
1.3 从 1883 到 8883:端口背后的语义变化
MQTT 标准端口有两个:1883 是明文 MQTT,8883 是 MQTT over TLS。这不是随便约定的,IANA 确实把这两个端口都分配给了 MQTT。如果你的服务端同时监听两个端口,相当于同时提供明文和加密两套入口。实践中我强烈建议公网环境只开放 8883,把 1883 关掉或者只绑定在内网接口上。原因很简单:只要 1883 暴露在公网上,攻击者就有机会绕过 TLS 直接拿明文凭据尝试撞库。安全设计的基本原则是减少攻击面,明文的入口能不开就不开。
2. 证书从哪里来:免费证书与自签名证书的真实差异
2.1 Let's Encrypt、云厂商免费证书与自签名证书对比
要给域名做 TLS,首先得有一张证书。证书的作用是把“域名”和“公钥”绑定在一起,并且由一个客户端信任的第三方(CA)来背书。常见的三个选择如下。
Let's Encrypt:免费,有效期 90 天,支持通配符域名,完全自动化续期,所有主流客户端和设备操作系统都信任它的根证书。适合绝大多数个人服务器和中小项目。
云厂商免费证书(阿里云、腾讯云等):免费,通常一年有效期,只签发单域名,不支持通配符(个别产品线有通配符但是付费的),申请需要登录控制台手动操作,续期也是手动点击。适合一次性部署、不想折腾自动化脚本的场景,但维护成本其实更高。
自签名证书:自己生成自己的 CA 并签发证书,完全免费,无有效期限制或自己控制。但是所有客户端都必须手动导入你的自签名 CA 证书,否则会直接拒绝连接。公网环境不推荐,内网测试或者公司内部设备的固件里预置了 CA 的场景可以用。
2.2 我的选择:Let's Encrypt + acme.sh
这个系列从第一篇开始我就强调工具选型要看“自动化程度”和“维护成本”。MQTT 服务搭完不是终点,后面还有设备接入、规则引擎、数据存储一堆事。证书如果每 90 天要手动换一次,那对运维来说是一种持续性的折磨。所以我选了 Let's Encrypt 配合 acme.sh 客户端,申请一次之后自动续期,cron 定时任务会帮你把证书在过期前自动换掉。
为什么不用云厂商的一年期免费证书?核心问题不是有效期长短,而是它不自动续期。一年之后你很可能已经忘了这件事,直到客户端突然全部离线、日志里刷出一片 certificate expired 才会想起来。而 Let's Encrypt 的 90 天有效期设计就是为了强制自动化,反而因为频繁轮换降低了“证书遗忘”的风险。
3. 申请证书之前的准备工作
3.1 域名解析:为什么 IP 直连不行
证书是绑在域名上的,不是绑在 IP 上的。你用 IP 直连 MQTT 服务也能连,但没法从 CA 申请到一张只包含 IP 地址的受信任证书(技术上存在 IP 证书,但免费渠道基本拿不到,而且物联网场景下 IP 会变,维护成本更高)。所以先把域名解析做好,添加一条 A 记录,把 MQTT 服务器所在的公网 IP 指到一个子域名上,比如 mqtt.example.com。
这里有个细节值得提醒:解析记录生效有延迟,DNS 的 TTL 如果设置太长,改完解析之后可能等很久才能生效。调试阶段建议把 TTL 设成 600 秒(10 分钟),确认一切稳定之后再改回 3600 以上,减少 DNS 查询压力。
3.2 端口规划与安全组放行
申请证书本身只需要 80 端口(HTTP 验证)或者 443 端口加 DNS API 验证。但 MQTT 服务要对外提供服务,需要提前规划好端口。如果你跟我一样用的是云服务器,登录云控制台在安全组里放行以下入方向规则。
| 端口 | 用途 | 建议 |
|---|---|---|
| 8883 | MQTT over TLS | 公网必须放行 |
| 1883 | 明文 MQTT | 公网关闭,或仅内网放行 |
| 8083 | MQTT over WebSocket | 按需放行 |
| 8084 | MQTT over WSS | 如果做 Web 端/小程序接入,必须放行 |
| 18083 | EMQX Dashboard | 建议只允许你当前办公网 IP 访问 |
安全组是云服务器最外层的一道闸门,有时候你在服务器内部用 firewalld 或者 iptables 放行了端口,但安全组没放行,从公网依然连不上。排错的时候先把安全组规则过一遍,能省很多时间。
3.3 DNS 验证的工作机制
Let's Encrypt 在签发证书之前需要验证你对这个域名有控制权。ACME 协议提供了两种主流验证方式。
HTTP-01 验证:CA 会访问http://你的域名/.well-known/acme-challenge/xxxx,要求返回一个特定 token。这种方式的先决条件是 80 端口能从公网访问,且该域名的 Web 服务能响应这个路径。
DNS-01 验证:CA 会要求你在域名的 DNS 解析记录里添加一条 TXT 记录,内容是一段随机字符串。用 API 操作 DNS 服务商自动添加记录,验证完成后自动删除。
对 MQTT 场景来说,如果你的服务器 80 端口已经被 Nginx 占用,或者服务器本身不跑 Web 服务,用 DNS-01 验证更省心。acme.sh 对阿里云、腾讯云、Cloudflare 等主流 DNS 服务商都提供了 API 自动接管。我自己的服务器上 Nginx 和 EMQX 同时跑,80 端口被占着,所以直接走 DNS-01,完全不需要动 Web 服务。
4. 实操:用 acme.sh 申请 Let's Encrypt 证书
4.1 安装 acme.sh
acme.sh 是一个用 Shell 写的 ACME 客户端,部署起来非常简单,一条命令搞定。
curl https://get.acme.sh | sh -s email=你的邮箱@example.com安装完成之后,它会自动配置好 shell 别名和 cron 定时任务。注意这里填的邮箱主要用来接收证书过期提醒,填一个你常用的邮箱即可。
安装完成之后验证一下:
acme.sh --version如果提示命令找不到,检查当前用户的.bashrc或.zshrc里有没有 acme.sh 的 alias,或者直接使用~/.acme.sh/acme.sh全路径执行。
4.2 签发证书
第一次使用先注册账号,然后直接发起签发请求。这里以 DNS-01 验证为例,使用 Cloudflare 的 API Token。
export CF_Token="你的Cloudflare_API_Token" export CF_Zone_ID="你的Zone_ID" acme.sh --issue --dns dns_cf -d mqtt.example.com如果你用的是阿里云 DNS,对应设置Ali_Key和Ali_Secret两个环境变量,然后--dns dns_ali。腾讯云则是dnspod_id和dnspod_key。
签发成功之后,证书文件会存放在~/.acme.sh/mqtt.example.com/目录下,我们需要的是这样几个文件:
~/.acme.sh/mqtt.example.com/fullchain.cer # 完整证书链 ~/.acme.sh/mqtt.example.com/mqtt.example.com.key # 私钥fullchain.cer里包含了你的服务器证书和中间证书,EMQX 和 Mosquitto 配置时用的就是完整证书链。不要只把服务器证书单独拷出来用,那样在某些客户端上会因为缺少中间证书导致证书链验证失败。
签发过程可能有延迟,DNS 记录创建之后需要等待几十秒到几分钟传播。acme.sh 默认会等待一段时间并自动重试验证,正常情况下一两分钟内能完成。
4.3 把证书安装到 MQTT 服务目录
acme.sh 的证书文件在~/.acme.sh目录下,这个目录被设计为 acme.sh 的内部工作目录,不建议直接让 EMQX 读取。推荐做法是用--install-cert命令把证书复制到服务目录,并指定 reload 命令。
acme.sh --install-cert -d mqtt.example.com \ --key-file /etc/emqx/certs/mqtt.example.com.key \ --fullchain-file /etc/emqx/certs/fullchain.cer \ --reloadcmd "systemctl reload emqx"重点是--reloadcmd参数。每次 acme.sh 自动续期成功之后,会执行你指定的 reload 命令,让 EMQX 重新加载新证书,不需要人工介入。如果你忘了配这个参数,续期之后证书文件虽然更新了,但运行中的 EMQX 还在用旧证书,过期之后客户端照样连不上。
5. 把证书配置到 MQTT 服务端
5.1 EMQX 5.x 的 TLS 监听器配置
EMQX 支持通过 Dashboard 可视化上传证书,也可以直接改emqx.conf配置文件。我倾向直接改配置,因为方便用版本管理工具跟踪。
EMQX 默认配置里已经有一个 8883 的 SSL 监听器,只是证书路径被指向了默认的certs/目录下的自带证书。修改/etc/emqx/emqx.conf,找到 SSL 监听器部分:
listeners.ssl.external { bind = "0.0.0.0:8883" ssl_options { keyfile = "/etc/emqx/certs/mqtt.example.com.key" certfile = "/etc/emqx/certs/fullchain.cer" cacertfile = "/etc/emqx/certs/fullchain.cer" } }保存后执行:
emqx reload或者重启服务:
systemctl restart emqxEMQX 会去读取你指定的证书和私钥文件。这里有一个容易踩的坑:EMQX 进程是以emqx用户身份运行的,如果私钥文件权限是 600 且 owner 是 root,EMQX 会直接报 permission denied 导致启动失败。处理方式是把证书目录 owner 改为 emqx,或者把私钥文件权限调整为 640,确保 emqx 用户能读。
5.2 Mosquitto 的 TLS 配置
如果你用的是 Mosquitto 而不是 EMQX,配置逻辑一样,只是文件格式不同。编辑/etc/mosquitto/mosquitto.conf:
listener 8883 cafile /etc/mosquitto/certs/fullchain.cer certfile /etc/mosquitto/certs/fullchain.cer keyfile /etc/mosquitto/certs/mqtt.example.com.key然后重启:
systemctl restart mosquittoMosquitto 和 EMQX 一个重要的区别:Mosquitto 的cafile参数是用于告诉客户端应该信任哪个 CA 来验证客户端证书的,如果你的目标是只验证服务端身份,把cafile指向我们自己的完整证书链即可;如果还要做客户端证书认证,需要额外配置require_certificate true和use_identity_as_username true。这个系列先不做双向认证,大家知道有这回事就行。
5.3 证书文件权限管理
证书文件本身是公开的,但私钥文件必须严格控制权限。我把经验总结为三条:
第一,私钥文件权限设为 600,owner 设为运行 MQTT 服务的用户。第二,不要用 root 身份跑 MQTT 服务。第三,每次续期替换私钥后,检查一下文件权限是否被重置。acme.sh 默认复制的文件权限是当前用户可读写,如果你用 root 跑的 acme.sh,复制出来的私钥可能只有 root 能读,这个时候 MQTT 服务就可能启动失败。
更稳妥的做法是在--install-cert命令之后加一条chown:
chown emqx:emqx /etc/emqx/certs/mqtt.example.com.key chmod 600 /etc/emqx/certs/mqtt.example.com.key把这个命令也写进续期后的 hook 脚本里,或者用 systemd service 文件里的 ExecStartPost 统一处理。
6. 客户端连接验证与常见排错
6.1 用 MQTTX 做首次连接验证
配置完成后,用 MQTTX 这类图形化客户端做一次连接测试最直观。新建连接时填写以下信息。
| 配置项 | 填写内容 |
|---|---|
| Host | mqtt.example.com |
| Port | 8883 |
| Username / Password | 你在 EMQX 里创建的用户名和密码 |
| SSL/TLS | 开启 |
| CA Certificate | 选择fullchain.cer文件 |
MQTTX 会通过系统信任库自动验证服务器证书,正常情况下不需要手动指定 CA,除非你用了自签名证书。如果你的操作系统信任了 Let's Encrypt 的根证书(现代操作系统基本都信任),只需要点击连接即可。
如果连接失败,最需要关注的是日志里的 TLS 层报错。MQTTX 这类客户端会把具体错误原因显示出来,这是排错的第一手信息。
6.2 用 openssl 命令快速验证 TLS 握手
图形化客户端能验证能不能连,但不能直观地看到证书链的完整状态。用 openssl 的s_client命令可以更底层地检查服务端 TLS 配置。
openssl s_client -connect mqtt.example.com:8883 -servername mqtt.example.com这个命令会输出完整的 TLS 握手信息,包括证书链、加密套件、协议版本。执行后重点关注这几项:
verify return code: 0 (ok),表示证书链验证通过。subject里 CN 或 SAN 要包含mqtt.example.com。issuer应该是 R 或 Let's Encrypt 的中间证书。
如果 verify 返回的代码非 0,说明证书链或者域名匹配有问题。常见错误是unable to get local issuer certificate,这说明服务端没有配置完整证书链,只发了服务器证书没发中间证书。
另外可以用下面这条命令直接查看证书文件和过期时间:
openssl x509 -in /etc/emqx/certs/fullchain.cer -noout -subject -dates6.3 嵌入式设备连接 TLS 的注意事项
如果你在 STM32 这类嵌入式设备上做 MQTT TLS 通信,情况比 PC 客户端复杂很多。嵌入式设备的内存小,很多设备使用的 mbedTLS 或者 WolfSSL 默认只加载了精简的受信任 CA 列表,甚至为了省空间根本不包含任何 CA。
在这种场景下,最可靠的做法是在设备固件里烧录完整的 CA 证书链,或者直接把 Let's Encrypt 的根证书(ISRG Root X1)内嵌到设备中。这里有一个容易踩的坑:Let's Encrypt 的证书链会定期轮换中间证书,如果设备固件里只硬编码了旧的中间证书,续期之后新证书由新的中间证书签发,设备就验证不过了。稳妥的方案是设备端只信任根证书,不要在固件里锁定中间证书,因为根证书几十年不变,中间证书却会轮换。
调试嵌入式设备时建议先用 PC 端 MQTTX 验证一次服务器证书没毛病,再怀疑设备端的 CA 配置问题。这样能把问题域切成“服务端问题”和“设备端问题”两部分。
6.4 续期之后客户端突然连不上
Let's Encrypt 证书每 90 天自动续期,正常情况下客户端无感知。但如果发生客户端突然在证书到期节点附近全部断开的情况,大概率是以下原因之一。
第一,EMQX 没有加载新证书。确认--install-cert的--reloadcmd是否配置正确,手动执行一次systemctl reload emqx看日志有没有报错。
第二,证书链不完整。acme.sh 续期时重新生成的fullchain.cer应该没问题,但如果你用的是云厂商的一年期证书,手动下载时一定要选“完整证书链”而不是“服务器证书”单独下载。
第三,设备端硬编码了旧证书指纹。有些安全要求高的设备会在固件里固定服务端证书的 SHA256 指纹做 pinning,证书一换就失联。这种设计对物联网设备来说是把双刃剑,换证书的操作成本极高。我的建议是:能用 CA 验证就不要 pin 指纹,确实需要 pin 的,一定要预留远程更新 pin 的通道。
最后再分享一个我实际运维过程中的小技巧:证书续期后不要直接覆盖原文件然后 reload,用软链接来管理证书路径。在证书目录里创建一个current软链接,指向当前正在使用的版本目录或者直接指向 acme.sh 生成的文件,EMQX 的配置只写死这个current路径。这样每次续期只需要把软链接重新指到新文件,服务无感切换,即使 reload 失败也能快速回滚到旧证书目录。这个思路在服务多了之后会非常省心,遇到 TLS 相关问题也能很快定位是不是证书文件被其他操作覆盖了。