一篇一篇写到第九篇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 是明文 MQTT8883 是 MQTT over TLS。这不是随便约定的IANA 确实把这两个端口都分配给了 MQTT。如果你的服务端同时监听两个端口相当于同时提供明文和加密两套入口。实践中我强烈建议公网环境只开放 8883把 1883 关掉或者只绑定在内网接口上。原因很简单只要 1883 暴露在公网上攻击者就有机会绕过 TLS 直接拿明文凭据尝试撞库。安全设计的基本原则是减少攻击面明文的入口能不开就不开。2. 证书从哪里来免费证书与自签名证书的真实差异2.1 Lets Encrypt、云厂商免费证书与自签名证书对比要给域名做 TLS首先得有一张证书。证书的作用是把“域名”和“公钥”绑定在一起并且由一个客户端信任的第三方CA来背书。常见的三个选择如下。Lets Encrypt免费有效期 90 天支持通配符域名完全自动化续期所有主流客户端和设备操作系统都信任它的根证书。适合绝大多数个人服务器和中小项目。云厂商免费证书阿里云、腾讯云等免费通常一年有效期只签发单域名不支持通配符个别产品线有通配符但是付费的申请需要登录控制台手动操作续期也是手动点击。适合一次性部署、不想折腾自动化脚本的场景但维护成本其实更高。自签名证书自己生成自己的 CA 并签发证书完全免费无有效期限制或自己控制。但是所有客户端都必须手动导入你的自签名 CA 证书否则会直接拒绝连接。公网环境不推荐内网测试或者公司内部设备的固件里预置了 CA 的场景可以用。2.2 我的选择Lets Encrypt acme.sh这个系列从第一篇开始我就强调工具选型要看“自动化程度”和“维护成本”。MQTT 服务搭完不是终点后面还有设备接入、规则引擎、数据存储一堆事。证书如果每 90 天要手动换一次那对运维来说是一种持续性的折磨。所以我选了 Lets Encrypt 配合 acme.sh 客户端申请一次之后自动续期cron 定时任务会帮你把证书在过期前自动换掉。为什么不用云厂商的一年期免费证书核心问题不是有效期长短而是它不自动续期。一年之后你很可能已经忘了这件事直到客户端突然全部离线、日志里刷出一片 certificate expired 才会想起来。而 Lets 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 服务要对外提供服务需要提前规划好端口。如果你跟我一样用的是云服务器登录云控制台在安全组里放行以下入方向规则。端口用途建议8883MQTT over TLS公网必须放行1883明文 MQTT公网关闭或仅内网放行8083MQTT over WebSocket按需放行8084MQTT over WSS如果做 Web 端/小程序接入必须放行18083EMQX Dashboard建议只允许你当前办公网 IP 访问安全组是云服务器最外层的一道闸门有时候你在服务器内部用 firewalld 或者 iptables 放行了端口但安全组没放行从公网依然连不上。排错的时候先把安全组规则过一遍能省很多时间。3.3 DNS 验证的工作机制Lets 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 申请 Lets Encrypt 证书4.1 安装 acme.shacme.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 是 rootEMQX 会直接报 permission denied 导致启动失败。处理方式是把证书目录 owner 改为 emqx或者把私钥文件权限调整为 640确保 emqx 用户能读。5.2 Mosquitto 的 TLS 配置如果你用的是 Mosquitto 而不是 EMQX配置逻辑一样只是文件格式不同。编辑/etc/mosquitto/mosquitto.conflistener 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 证书文件权限管理证书文件本身是公开的但私钥文件必须严格控制权限。我把经验总结为三条第一私钥文件权限设为 600owner 设为运行 MQTT 服务的用户。第二不要用 root 身份跑 MQTT 服务。第三每次续期替换私钥后检查一下文件权限是否被重置。acme.sh 默认复制的文件权限是当前用户可读写如果你用 root 跑的 acme.sh复制出来的私钥可能只有 root 能读这个时候 MQTT 服务就可能启动失败。更稳妥的做法是在--install-cert命令之后加一条chownchown 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 这类图形化客户端做一次连接测试最直观。新建连接时填写以下信息。配置项填写内容Hostmqtt.example.comPort8883Username / Password你在 EMQX 里创建的用户名和密码SSL/TLS开启CA Certificate选择fullchain.cer文件MQTTX 会通过系统信任库自动验证服务器证书正常情况下不需要手动指定 CA除非你用了自签名证书。如果你的操作系统信任了 Lets 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 或 Lets 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 证书链或者直接把 Lets Encrypt 的根证书ISRG Root X1内嵌到设备中。这里有一个容易踩的坑Lets Encrypt 的证书链会定期轮换中间证书如果设备固件里只硬编码了旧的中间证书续期之后新证书由新的中间证书签发设备就验证不过了。稳妥的方案是设备端只信任根证书不要在固件里锁定中间证书因为根证书几十年不变中间证书却会轮换。调试嵌入式设备时建议先用 PC 端 MQTTX 验证一次服务器证书没毛病再怀疑设备端的 CA 配置问题。这样能把问题域切成“服务端问题”和“设备端问题”两部分。6.4 续期之后客户端突然连不上Lets 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 相关问题也能很快定位是不是证书文件被其他操作覆盖了。