Mosquitto + ACME:从 Docker Compose 部署到 TLS 证书自动续期与加载

让 MQTT 服务启用 TLS,难点往往不在第一次申请证书,而在几个月后的自动续期:CA 已签发新证书,磁盘已写入新文件,Broker 却仍然提供旧证书。本文把部署、认证、ACL、ACME、systemd 和服务验证串成一个闭环,并专门处理容器停止、事件漏触发、证书配对和目录挂载问题。

面向熟悉基本 Linux 命令的 Docker/NAS/家庭服务器与 IoT 用户。参考实现运行于 具有 systemd 的 Linux 宿主机、Docker Engine + Compose v2、Bash、OpenSSL 3、GNU coreutils 和 util-linux;Windows 只作为本文编写及本地静态检查环境。非 systemd NAS 需换用其调度机制;大型集群、高可用消息平台、设备全生命周期管理不在本文范围。

1. MQTT、TLS 与权限的基本关系

MQTT(Message Queuing Telemetry Transport)是发布/订阅消息协议。Publisher 把消息发到 Topic,Subscriber 向 Broker 登记主题过滤器,Broker 根据订阅与权限转发消息。Mosquitto 是 Eclipse 的 MQTT Broker,支持 MQTT 3.1、3.1.1 和 5.0,适用于传感器遥测、智能家居与内部消息分发;它不是完整的设备管理平台。Mosquitto 项目说明

图 0:发布者向 Broker 发布,订阅者先提交订阅,再接收匹配主题的消息。箭头表示不同操作,不表示设备直接互连。

1.1 部署需要理解的协议特性

特性 实际含义与使用边界
QoS 0 至多一次,无 MQTT 层确认;适合允许丢失的高频遥测
QoS 1 至少一次,重试可能重复;消费端要按业务标识去重
QoS 2 恰好一次的 MQTT 协议交付流程,开销更大;不能推出业务数据库事务也恰好一次
Retain Broker 为主题保存最近一条 retained 消息,供后续匹配订阅获取;不是消息历史库
Keep Alive 客户端保持通信活跃的约定,必要时发 PING;不是 TCP 连接永久在线保证
Topic 区分大小写、按 / 分层;订阅中的 + 匹配单层,# 匹配多层且必须位于末尾
会话 持久会话依赖稳定 Client ID、协议参数、Broker 配置和资源限制;不等于无限期离线消息存储

MQTT 3.1.1 的 Clean Session=false 允许恢复会话。MQTT 5.0 把"是否清除旧会话"的 Clean Start 与断连后的 Session Expiry Interval 分开;只设置 Clean Start=false、仍采用默认零到期时间,不能据此保证断连后保留会话。MQTT 5.0 还增加原因码、消息到期、属性、流控等能力。QoS 要逐段理解,发布段 QoS 不能无条件决定订阅段的实际交付等级。MQTT 3.1.1 规范,MQTT 5.0 规范

1.2 TLS、登录和 ACL 各解决什么

TLS(Transport Layer Security)保护链路机密性和完整性,并让客户端验证服务端身份;用户名/密码决定谁可以登录;ACL(Access Control List)决定该身份可以读写哪些主题。三者需要同时落实。密码认证若通过明文 TCP,凭据也可能被截获。

端口 常见用途 本文参考
1883/TCP 明文 MQTT/TCP 的惯例端口 不配置、不发布
8883/TCP MQTT over TLS 的惯例端口 明确 TLS listener,默认只发布到宿主机回环
自定义端口 由 listener 和端口映射共同决定 需要同步修改探测地址与客户端
WebSocket/WSS MQTT 经 HTTP WebSocket 帧承载;常用于浏览器 不启用,需另建 listener

端口号不能证明 TLS 已启用:必须配置 certfile/keyfile 并实测握手。1883 一般无需公网开放;8883 也只应在确需远程访问时对指定来源放行,内部服务可走 LAN/VPN。

服务端证书证明 Broker 身份;客户端证书由设备携带,在 require_certificate true 时实现双向 TLS(mTLS)。mTLS 要管理设备私钥、专用签发 CA、吊销与身份映射;它不是把同一份 Broker 私钥分给所有设备。本文选择单向 TLS + MQTT 密码 + ACL。Mosquitto 2.0.22 配置文档

2. 部署架构、目录和准备

图 1:Mosquitto 提供 MQTT/TLS,ACME 容器维护证书;systemd 和 Docker 管理在宿主机上。证书同步是一条独立的文件路径。

Docker Compose 使镜像、挂载、网络和日志配置可记录、可重建。本文使用两个独立 Compose 项目,避免停止 MQTT 时顺带停掉证书续期。Mosquitto 采用 bridge 网络,通过 ports 发布 8883;DNS-01 不需要 ACME 容器占用 80 或 443,也不需要 Docker socket。

2.1 镜像、架构和进程身份

官方镜像清单在核查时列出 Mosquitto 2.0.22 与 2.1.2 系列,并包含 amd64、arm64v8 等架构。本文刻意固定 2.0.22 作为 2.0 分支参考,不声称是最新版本或原项目版本 。上线前审查安全公告和目标平台 manifest;跨到 2.1 应重审已弃用配置和插件迁移。Docker Official Images 清单

latest、2 等标签会变化,长期复现应保存镜像 digest、实际 Broker 版本与架构。拉取和重启不是同一件事:restart 使用原容器,pull 后 up 才可能重建。参考 Compose 显式 user: "1883:1883",配合该镜像的用户定义;官方入口脚本最终 exec Broker,因此 HUP 送给容器 PID 1 可到达 Mosquitto。自定义包装脚本或启用额外 init 后必须重新确认信号转发。2.0 镜像构建文件,入口脚本

2.2 环境和目录

下列命令在 目标 Linux 的 root Bash 执行。Ubuntu/Debian 示例依赖准备:

bash 复制代码
apt-get update
apt-get install -y ca-certificates openssl util-linux coreutils mosquitto-clients
docker version
docker compose version
systemctl --version
openssl version

Docker Engine 的安装采用目标发行版官方方法,不把一条不透明安装脚本作为必需前提。检查时钟同步、DNS 出站解析、HTTPS 出站到 ACME/DNS API、磁盘空间,以及 systemd/inotify 能监测的本地文件系统。1883/8883 是否被其他程序占用也要提前确认。

目标布局:

text 复制代码
/opt/mqtt-stack/acme/
├── compose.yaml  .env  dns.env
├── state/                     # 账户、续期参数、DNS 凭据缓存
├── hooks/publish-cert.sh       # 容器内成功安装通知
└── certs/mqtt.example.com/
    ├── fullchain.pem  privkey.pem   # acme.sh 安装导出区
    ├── .renewed                    # 最近成功导出的代次 ID
    ├── current -> releases/<ID>
    └── releases/<ID>/              # 完成校验后固定的源快照

/opt/mqtt-stack/mosquitto/
├── compose.yaml  .env
├── config/
│   ├── mosquitto.conf  passwords  acl
│   └── certs/
│       ├── current -> releases/<ID>
│       └── releases/<ID>/fullchain.pem  privkey.pem
└── data/mosquitto.db

/etc/systemd/system/mqtt-cert-deploy.{path,service,timer}
/etc/default/mqtt-cert-deploy
/usr/local/sbin/deploy-mqtt-cert.sh
/var/lib/mqtt-cert-deploy/applied

stdout 交给 Docker 做日志轮转,不再重复写容器 log 目录。如实际选择 log_dest file,需额外挂载日志目录、给 Broker 写权限并配置轮转。

把交付目录传到 Linux,保留相对结构。以下安装命令只适用于新建实验部署;已有同名生产目录或单元先备份并改名实验,勿覆盖历史多服务脚本。

bash 复制代码
DELIVERY="$PWD"
install -d -m 0700 /opt/mqtt-stack/acme
install -d -m 0700 /opt/mqtt-stack/acme/state /opt/mqtt-stack/acme/certs
install -d -m 0700 /opt/mqtt-stack/acme/certs/mqtt.example.com
install -d -m 0750 /opt/mqtt-stack/acme/hooks
install -d -m 0750 /opt/mqtt-stack/mosquitto/config/certs
install -d -o 1883 -g 1883 -m 0750 /opt/mqtt-stack/mosquitto/data
install -m 0644 "$DELIVERY/配置附件/acme/compose.yaml" /opt/mqtt-stack/acme/compose.yaml
install -m 0750 "$DELIVERY/配置附件/acme/hooks/publish-cert.sh" /opt/mqtt-stack/acme/hooks/publish-cert.sh
install -m 0600 "$DELIVERY/配置附件/acme/dns.env.example" /opt/mqtt-stack/acme/dns.env
install -m 0644 "$DELIVERY/配置附件/mosquitto/compose.yaml" /opt/mqtt-stack/mosquitto/compose.yaml
install -m 0600 "$DELIVERY/配置附件/mosquitto/.env.example" /opt/mqtt-stack/mosquitto/.env
install -o root -g 1883 -m 0640 "$DELIVERY/配置附件/mosquitto/config/mosquitto.conf" /opt/mqtt-stack/mosquitto/config/mosquitto.conf
install -o root -g 1883 -m 0640 "$DELIVERY/配置附件/mosquitto/config/acl" /opt/mqtt-stack/mosquitto/config/acl
chown root:1883 /opt/mqtt-stack/mosquitto/config /opt/mqtt-stack/mosquitto/config/certs
chmod 0750 /opt/mqtt-stack/mosquitto/config /opt/mqtt-stack/mosquitto/config/certs

文件均使用 LF 换行。rootful Docker 且未启用 user namespace remap 是本文 UID 映射前提;rootless/remap 部署要按实际映射重新设计所有权,不能照抄数值后用 chmod 777 补救。

3. ACME DNS-01 首次签发与通知

3.1 证书与验证机制

PKI(Public Key Infrastructure)通过 CA(Certificate Authority,证书颁发机构)建立信任链。客户端信任根 CA,服务器发送叶证书和中间证书链;SAN(Subject Alternative Name)应包含连接域名。私钥留在服务器,用于证明持有证书对应密钥,不能公开。fullchain.pem 通常是叶证书在前、中间证书随后,通常不需要附带根证书;只有叶证书可能导致部分客户端链构建失败。自签名或私有 CA 可用于内网,但客户端需另行、安全地配置可信根。

ACME(Automatic Certificate Management Environment)自动完成订单、域名授权、CSR 签名和证书获取。ACME 本身不承载 MQTT 流量。RFC 8555

续期信息扩展 ARI(ACME Renewal Information)允许 CA 提供建议续期窗口,已由 RFC 9773 标准化。固定客户端是否启用 ARI 要查版本、CA directory 和日志;不能把理论支持当成本项目已验证,也不要以固定"每60天签发"替代实际续期判定。RFC 9773

验证方式 必要条件 与 MQTT 的关系
HTTP-01 CA 能访问域名的 HTTP 80 端口挑战路径;不支持通配符签发 80 为验证而用,不表示 MQTT 必须提供 HTTP
DNS-01 能自动创建公开权威 DNS 的 _acme-challenge TXT;等待传播 Broker 可只在内网;API 权限是关键
手工 DNS 人工添加 TXT 不适合无人值守续期,不能冒充自动化

本方案选 DNS API 方式;其他提供商替换 --dns dns_cf 及对应变量,不能保留 Cloudflare 参数去请求另一个 DNS 服务。Let's Encrypt 挑战类型

证书寿命不能写死为"ACME 证书一律 90 天"。核查时 Let's Encrypt 文档的 classic、tlsserver、shortlived profile 分别有不同寿命,其中 classic 为 90 天、tlsserver 为 45 天;实际以 CA directory 的可用 profile 和证书 notAfter 为准。续期交给所用客户端版本的调度策略,并持续监测到期余量,不把固定天数硬编码成普遍政策。Let's Encrypt Profiles

3.2 ACME Compose 和凭据

保存到 /opt/mqtt-stack/acme/compose.yaml:

yaml 复制代码
name: mqtt-acme
services:
  acme:
    image: ${ACME_IMAGE:?set a verified acme.sh image digest in .env}
    container_name: acme
    restart: unless-stopped
    command: daemon
    environment:
      AUTO_UPGRADE: "0"
    env_file:
      - ./dns.env
    volumes:
      - ./state:/acme.sh
      - ./certs:/export
      - ./hooks:/hooks:ro
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

state 必须持久化,否则丢失账户和安装路径参数。certs 是对外导出区,不直接把 acme.sh 的内部账户/证书目录挂给 Broker。--install-cert 会记录输出路径与 reloadcmd,后续更新可再运行该安装逻辑。acme.sh 官方说明,Docker 运行说明

编辑 /opt/mqtt-stack/acme/dns.env,只填自己 DNS zone 的 Token:

dotenv 复制代码
CF_Token=REPLACE_WITH_ZONE_SCOPED_DNS_EDIT_TOKEN
CF_Account_ID=REPLACE_WITH_ACCOUNT_ID
CF_Zone_ID=REPLACE_WITH_ZONE_ID

Cloudflare 示例 Token 仅授予目标 zone 的 DNS Edit,所需 zone 查询权限按插件/API 路径设置;填写 Zone ID 可以缩小查询范围。不要使用账户级 Global API Key。env_file 可以被具有 Docker 管理权限的人通过 inspect 获取,acme.sh 也可能把参数缓存到 state。dns.env 0600、state 0700、备份加密 都需要落实。新版镜像支持的 _FILE secrets 方式可作为增强,需对固定镜像实测后启用。dns_cf 插件源码

拉取官方 ACME 镜像并把实际 digest 写入 .env;首次从 latest 获取的是待审查候选,随后固定,不编造 digest:

bash 复制代码
cd /opt/mqtt-stack/acme
docker pull ghcr.io/acmesh-official/acme.sh:latest
ACME_DIGEST=$(docker image inspect ghcr.io/acmesh-official/acme.sh:latest --format '{{index .RepoDigests 0}}')
printf 'ACME_IMAGE=%s\n' "$ACME_DIGEST" > .env
chmod 0600 .env
docker buildx imagetools inspect "$ACME_DIGEST"
docker compose config --quiet
docker compose run --rm acme acme.sh --version
docker compose run --rm acme sh -c 'command -v openssl; command -v sha256sum; mv --help'

确认该镜像包含 OpenSSL 和支持 mv -T 的工具。保存 digest 与版本到自己的部署记录;不要以本文核查的 master 行为保证任意历史镜像都兼容。

初次准备使用一次性容器,完成签发与 install-cert 后才启动 daemon,避免和定时任务并行。当前官方镜像将程序放在 /acmebin、配置放在 /acme.sh;不要把配置目录误传为程序 --home。Compose 设置 AUTO_UPGRADE=0,并在账户初始化时显式保存零值,避免运行期程序自动升级削弱 digest 固定的可复现性。迁移旧 state 时须核对 account.conf 中该值,不要假定环境能覆盖已保存配置。官方 Dockerfile

3.3 签发与注册安装 hook

用你自己的账户邮箱,明确选择 CA,使用 ECC P-256;所有命令保持同一域名与 --ecc:

bash 复制代码
cd /opt/mqtt-stack/acme
docker compose run --rm acme acme.sh --register-account --auto-upgrade 0 --server letsencrypt -m admin@example.com
docker compose run --rm acme acme.sh --issue --server letsencrypt --dns dns_cf -d mqtt.example.com --keylength ec-256
docker compose run --rm acme acme.sh --install-cert -d mqtt.example.com --ecc \
  --key-file /export/mqtt.example.com/privkey.pem \
  --fullchain-file /export/mqtt.example.com/fullchain.pem \
  --reloadcmd "/bin/sh /hooks/publish-cert.sh"
docker compose up -d acme
docker compose exec acme sh -c 'cat /acme.sh/crontab'

账户邮箱和域名都必须替换。先通过 staging CA 完整测试可减少生产限额消耗,但 staging 证书不能用于最终公开可信连接;实验应使用隔离目录和 CA trust,不把 staging 链拷入生产。

成功安装后 hook 会构造不可变快照并写入 .renewed。首次手工 install-cert 和安装重试也会触发 hook,所以标记含义是"证书导出完成",不是"本次一定产生新签发"。普通无需续期的定时检查不应被误记成新证书更新。通知必须在完整导出之后产生。acme.sh 安装与 reloadcmd 实现

图 2:ACME 客户端通过 DNS API 创建 TXT,CA 查询权威 DNS 验证后签发。MQTT 数据流不经过 CA 或 DNS API。

完整 hook,保存于 /opt/mqtt-stack/acme/hooks/publish-cert.sh,在容器中通过 /hooks 访问:

sh 复制代码
#!/bin/sh
# Runs INSIDE the ACME container, ONLY after --install-cert completes.
set -eu
umask 077
domain=${DOMAIN:-mqtt.example.com}
base=${EXPORT_BASE:-/export/$domain}
stage=
lock="$base/.publish-lock"
mkdir "$lock" || { echo "ERROR: publisher already active; investigate stale lock" >&2; exit 1; }
cleanup() {
    [ -z "$stage" ] || rm -rf -- "$stage"
    rm -f -- "$base/.current-new" "$base/.renewed-new"
    rmdir "$lock"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
test -s "$base/fullchain.pem"
test -s "$base/privkey.pem"
mkdir -p "$base/releases"
stage=$(mktemp -d "$base/releases/.stage.XXXXXX")
cp "$base/fullchain.pem" "$stage/fullchain.pem"
cp "$base/privkey.pem" "$stage/privkey.pem"
openssl x509 -in "$stage/fullchain.pem" -noout -checkend 86400
openssl x509 -in "$stage/fullchain.pem" -noout -checkhost "$domain"
openssl pkey -in "$stage/privkey.pem" -passin pass: -check -noout
openssl x509 -in "$stage/fullchain.pem" -pubkey -noout > "$stage/cert.pub"
openssl pkey -pubin -in "$stage/cert.pub" -outform DER > "$stage/cert.der"
openssl pkey -in "$stage/privkey.pem" -passin pass: -pubout -outform DER > "$stage/key.der"
cmp "$stage/cert.der" "$stage/key.der"
rm "$stage/cert.pub" "$stage/cert.der" "$stage/key.der"
id=$(cat "$stage/fullchain.pem" "$stage/privkey.pem" | sha256sum | cut -d ' ' -f 1)
if [ -d "$base/releases/$id" ]; then
    cmp "$stage/fullchain.pem" "$base/releases/$id/fullchain.pem"
    cmp "$stage/privkey.pem" "$base/releases/$id/privkey.pem"
else
    mv "$stage" "$base/releases/$id"
    stage=
fi
ln -s "releases/$id" "$base/.current-new"
mv -Tf "$base/.current-new" "$base/current"
# Stable marker inode; writing + closing emits PathChanged.
# It is an installation notification, NOT proof of a new CA issuance.
printf '%s\n' "$id" > "$base/.renewed"
echo "Published certificate generation $id"

这个 hook 的锁防止两个 publisher 同时工作,但不能代替 ACME 实例之间的串行化。同一 state/export 只运行一个 ACME daemon,不同时启动另一个 issue/renew/install 命令;手动续期检查时先停 daemon,再用 Compose run,结束后恢复 daemon。异常退出留下 .publish-lock 时,先确认没有写入进程,再人工处理残留锁。不能盲目定时删除锁。

.renewed 写入 64 位十六进制代次 ID,宿主机据此读取固定 releases/<ID>,不直接读取可能正在覆盖的导出文件。current 供运维查看;部署时使用标记指向的固定快照。失败时不发布新标记,旧目标证书保持可用。这里的 hash 是完整证书与私钥字节的代次标识,不是访问凭据,日志没有私钥内容。

4. Mosquitto 配置、认证与首次启动

4.1 Compose

保存到 /opt/mqtt-stack/mosquitto/compose.yaml:

yaml 复制代码
name: mqtt-secure
services:
  mosquitto:
    image: ${MOSQUITTO_IMAGE:?set MOSQUITTO_IMAGE in .env}
    container_name: mosquitto
    user: "1883:1883"
    restart: unless-stopped
    ports:
      - "${MQTT_BIND_IP:-127.0.0.1}:8883:8883"
    volumes:
      - ./config:/mosquitto/config:ro
      - ./data:/mosquitto/data
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    stop_grace_period: 30s

.env 内容:

dotenv 复制代码
MOSQUITTO_IMAGE=eclipse-mosquitto:2.0.22
# Replace with the host LAN IP or 0.0.0.0 ONLY after firewall review.
MQTT_BIND_IP=127.0.0.1

默认回环只适合宿主机本地验证。需要 LAN 接入时,把 MQTT_BIND_IP 改成目标宿主机 LAN 地址,同时更新部署脚本的 PROBE_ADDR 为该地址:8883;需要公网接入再结合路由、防火墙和来源限制。不要把"只映射一个 TLS 端口"理解为自动完成公网安全设计。

配置目录整体只读挂载,data 可写。restart: unless-stopped 处理容器退出/daemon 恢复,不参与证书签发;脚本不会通过 up/restart 把停止的容器拉起。目录挂载让新代次和 current 替换在容器命名空间中可见。单文件 bind mount 可能继续引用旧 inode ,原子替换后容器不一定读到宿主机新文件;不要把 current 符号链接本身当作单独挂载源。Docker bind mounts,restart policy

4.2 mosquitto.conf 与 ACL

保存到 /opt/mqtt-stack/mosquitto/config/mosquitto.conf:

conf 复制代码
persistence true
persistence_location /mosquitto/data/
autosave_interval 300
log_dest stdout
log_type error
log_type warning
log_type notice
log_type information
connection_messages true
allow_anonymous false
password_file /mosquitto/config/passwords
acl_file /mosquitto/config/acl
listener 8883
protocol mqtt
certfile /mosquitto/config/certs/current/fullchain.pem
keyfile /mosquitto/config/certs/current/privkey.pem
tls_version tlsv1.2
require_certificate false

显式 listener 使行为可复现;Mosquitto 2.x 在未配置 listener 时默认只接受本机连接,不能靠 Docker EXPOSE 让它向外监听。这里全局 allow_anonymous=false,不使用多 listener 的 per_listener_settings 以降低歧义。持久化定期写盘,异常断电仍可能丢失尚未落盘的状态,不能当成零丢失事务库。

certfile 使用 fullchain,keyfile 使用私钥;tls_version tlsv1.2 在本分支表示最低 TLS 版本。单向 TLS 不要求客户端证书,因此没有设置 Broker 的 cafile;Broker cafile 用于信任入站客户端证书,不是发送服务端中间链的替代品。客户端另用系统 CA 文件验证 Broker。

保存到 /opt/mqtt-stack/mosquitto/config/acl:

conf 复制代码
user sensor01
topic write lab/sensor01/telemetry
topic read lab/sensor01/command

user operator
topic read lab/sensor01/telemetry
topic write lab/sensor01/command

sensor01 只能写自身遥测、读自身命令;operator 反向权限。未列出的主题不授予权限。扩展到多个设备可以使用 username 模板,但 +、# 会扩大范围,给普通设备 topic readwrite # 会失去隔离。不要用用户可随意选择的 Client ID 充当唯一可信设备身份。

4.3 交互创建密码

启动 Broker 前先拉取镜像,用一次性容器中的 passwd 工具交互输入密码,避免 -b user password 暴露到进程列表。此时临时容器允许写 config,不启动 MQTT listener:

bash 复制代码
cd /opt/mqtt-stack/mosquitto
docker compose config --quiet
docker compose pull
docker compose run --rm --no-deps --user 0:0 \
  --entrypoint mosquitto_passwd \
  -v /opt/mqtt-stack/mosquitto/config:/mosquitto/config:rw \
  mosquitto -c /mosquitto/config/passwords sensor01
docker compose run --rm --no-deps --user 0:0 \
  --entrypoint mosquitto_passwd \
  -v /opt/mqtt-stack/mosquitto/config:/mosquitto/config:rw \
  mosquitto /mosquitto/config/passwords operator
chown root:1883 config/passwords
chmod 0640 config/passwords
docker compose run --rm --no-deps --entrypoint id mosquitto
docker compose run --rm --no-deps --entrypoint mosquitto mosquitto -h

-c 只在首次创建 passwords 时使用,再次执行会覆盖已有用户。核对输出 UID/GID 是否为 1883。如果自己的 Compose 覆盖卷行为不同,检查 docker compose config,确认一次性容器的目标路径实际为 rw。

首次启动前需要已经存在有效证书。本节暂不执行 up,先完成下一节的脚本部署,然后回到启动命令。不要以临时匿名明文 listener 绕过证书准备。

5. systemd 自动化和完整部署脚本

图 3:定时检查、CA 续期、导出通知、磁盘部署、HUP 和远端指纹确认是不同事件。补偿 timer 复查状态,不进行 CA 签发。

5.1 为什么监听标记

systemd.path 使用 inotify。PathChanged= 在变化和写入关闭等事件触发;PathModified= 还可在简单写操作时触发,容易读到尚未写完的数据。这里显式监听一个稳定文件 .renewed,hook 用 printf 写入并关闭,避免仅依赖 touch 的时间戳语义。文件存在本身不能代表一次新变化,PathChanged 启用时也不会因为已存在的文件而自动补历史事件。systemd.path 官方手册源

不要改成监听整个目录后假定一定发现以点开头的文件;手册指出目录扫描通常忽略隐藏名。显式文件监听仍应在目标 systemd 版本上测试。原地写、删除重建、rename 原子替换、符号链接目标更换的事件不同;本文固定标记 inode,并加 timer 弥补 watcher 停止、事件合并、部署中出现新代次等情况。远程 NFS 的其他机器变更不适用本地 inotify 保证。

5.2 部署脚本

保存为 /usr/local/sbin/deploy-mqtt-cert.sh。完整代码与附件一致:

bash 复制代码
#!/usr/bin/env bash
# Reference implementation for Linux/OpenSSL 3; no production changes by this task.
set -Eeuo pipefail
umask 077
DOMAIN=${DOMAIN:-mqtt.example.com}
SRC=${SRC:-/opt/mqtt-stack/acme/certs/mqtt.example.com}
DST=${DST:-/opt/mqtt-stack/mosquitto/config/certs}
STATE=${STATE:-/var/lib/mqtt-cert-deploy}
LOCK=${LOCK:-/run/lock/mqtt-cert-deploy.lock}
CONTAINER=${CONTAINER:-mosquitto}
BROKER_UID=${BROKER_UID:-1883}
BROKER_GID=${BROKER_GID:-1883}
CAFILE=${CAFILE:-/etc/ssl/certs/ca-certificates.crt}
PROBE_ADDR=${PROBE_ADDR:-127.0.0.1:8883}
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*"; }
die() { log "ERROR: $*" >&2; exit 1; }
[[ $EUID == 0 ]] || die "run as root"
for tool in openssl flock timeout docker sha256sum install cmp; do
    command -v "$tool" >/dev/null || die "missing command: $tool"
done
mkdir -p "$(dirname "$LOCK")"
exec 9>"$LOCK"
flock -w 10 9 || die "deployment lock busy"
[[ -s "$SRC/.renewed" ]] || die "installation marker missing or empty"
IFS= read -r gen < "$SRC/.renewed"
[[ $gen =~ ^[0-9a-f]{64}$ ]] || die "invalid generation marker"
[[ -r "$CAFILE" ]] || die "trust store unreadable"
source_release="$SRC/releases/$gen"
[[ -d "$source_release" && ! -L "$source_release" ]] || die "source generation missing or symlink"
install -d -o root -g "$BROKER_GID" -m 0750 "$DST" "$DST/releases"
install -d -o root -g root -m 0700 "$STATE"
work=$(mktemp -d "$DST/releases/.stage.XXXXXX")
probe=
linktmp=
statetmp=
cleanup() {
    [[ -z $work ]] || rm -rf -- "$work"
    [[ -z $probe ]] || rm -f -- "$probe"
    [[ -z $linktmp ]] || rm -f -- "$linktmp"
    [[ -z $statetmp ]] || rm -f -- "$statetmp"
}
trap cleanup EXIT
trap 'log "ERROR: failed at line $LINENO" >&2' ERR
trap 'exit 1' HUP INT TERM
install -m 0600 "$source_release/fullchain.pem" "$work/fullchain.pem"
install -m 0600 "$source_release/privkey.pem" "$work/privkey.pem"
actual=$(cat "$work/fullchain.pem" "$work/privkey.pem" | sha256sum | cut -d ' ' -f 1)
[[ $actual == "$gen" ]] || die "generation digest mismatch"
openssl x509 -in "$work/fullchain.pem" -noout -checkend 86400 >/dev/null
openssl x509 -in "$work/fullchain.pem" -out "$work/leaf.pem"
openssl pkey -in "$work/privkey.pem" -passin pass: -check -noout >/dev/null
openssl x509 -in "$work/leaf.pem" -pubkey -noout > "$work/cert.pub"
openssl pkey -pubin -in "$work/cert.pub" -outform DER > "$work/cert.der"
openssl pkey -in "$work/privkey.pem" -passin pass: -pubout -outform DER > "$work/key.der"
cmp -s "$work/cert.der" "$work/key.der" || die "certificate/private key mismatch"
openssl verify -purpose sslserver -verify_hostname "$DOMAIN" \
    -CAfile "$CAFILE" -untrusted "$work/fullchain.pem" "$work/leaf.pem"
expected=$(openssl x509 -in "$work/leaf.pem" -outform DER | sha256sum | cut -d ' ' -f 1)
rm "$work/leaf.pem" "$work/cert.pub" "$work/cert.der" "$work/key.der"
chown root:"$BROKER_GID" "$work"
chmod 0750 "$work"
chown root:"$BROKER_GID" "$work/fullchain.pem" "$work/privkey.pem"
chmod 0644 "$work/fullchain.pem"
chmod 0640 "$work/privkey.pem"
release="$DST/releases/$gen"
if [[ -e $release ]]; then
    [[ -d $release && ! -L $release ]] || die "unsafe target generation"
    cmp -s "$work/fullchain.pem" "$release/fullchain.pem" || die "target certificate differs"
    cmp -s "$work/privkey.pem" "$release/privkey.pem" || die "target key differs"
    chown root:"$BROKER_GID" "$release" "$release/fullchain.pem" "$release/privkey.pem"
    chmod 0750 "$release"
    chmod 0644 "$release/fullchain.pem"
    chmod 0640 "$release/privkey.pem"
else
    mv "$work" "$release"
    work=
fi
if [[ -e "$DST/current" && ! -L "$DST/current" ]]; then
    die "current must be a symlink, do not overwrite an existing directory"
fi
if [[ $(readlink "$DST/current" 2>/dev/null || true) != "releases/$gen" ]]; then
    linktmp="$DST/.current.$BASHPID"
    ln -s "releases/$gen" "$linktmp"
    mv -Tf "$linktmp" "$DST/current"
    linktmp=
    log "DISK_DEPLOYED generation=$gen"
else
    log "DISK_UNCHANGED generation=$gen"
fi
# Disk deployment intentionally precedes Docker checks; never start/create a broker.
timeout 10 docker info >/dev/null 2>&1 || die "disk updated; Docker unavailable; retry later"
ids=$(timeout 10 docker ps -a --format '{{.Names}}')
if ! printf '%s\n' "$ids" | grep -Fxq "$CONTAINER"; then
    log "CONTAINER_ABSENT: disk ready; no container created; runtime unverified"
    exit 0
fi
info=$(timeout 10 docker inspect --format '{{.State.Status}} {{.Id}} {{.State.StartedAt}}' "$CONTAINER")
read -r status cid started <<< "$info"
case "$status" in
    exited|created)
        log "CONTAINER_STOPPED: disk ready; no start requested; runtime unverified"
        exit 0 ;;
    running) ;;
    *) die "disk ready; container status=$status; requires attention" ;;
esac
token="$gen $cid $started"
applied=$(cat "$STATE/applied" 2>/dev/null || true)
# PID 1 must be Mosquitto, as it is in the accompanying official-image Compose.
if [[ $applied != "$token" ]]; then
    timeout 10 docker kill --signal=HUP "$CONTAINER" >/dev/null
    log "HUP_SENT: not yet proof of active certificate"
fi
probe=$(mktemp "$STATE/.probe.XXXXXX")
ok=false
for attempt in 1 2 3 4 5; do
    if timeout 12 openssl s_client -connect "$PROBE_ADDR" -servername "$DOMAIN" \
        -verify_hostname "$DOMAIN" -verify_return_error -CAfile "$CAFILE" \
        -showcerts < /dev/null > "$probe" 2>&1; then
        observed=$(openssl x509 -in "$probe" -outform DER 2>/dev/null | sha256sum | cut -d ' ' -f 1) || observed=
        if [[ $observed == "$expected" ]]; then ok=true; break; fi
    fi
    sleep 2
done
[[ $ok == true ]] || die "disk updated but TLS verification/fingerprint failed; no applied state recorded"
# Ensure a container restart/stop race is not recorded as success.
after=$(timeout 10 docker inspect --format '{{.State.Status}} {{.Id}} {{.State.StartedAt}}' "$CONTAINER")
[[ $after == "$info" ]] || die "container changed during verification; retry"
# A newer marker is picked up by the next path activation or the reconciliation timer.
statetmp=$(mktemp "$STATE/.applied.XXXXXX")
printf '%s\n' "$token" > "$statetmp"
mv -f "$statetmp" "$STATE/applied"
statetmp=
log "TLS_VERIFIED: expected leaf fingerprint active; generation=$gen"

脚本先锁定部署,再复制标记指定快照到私有临时目录;检查文件 hash、私钥格式、证书/私钥公钥一致性、最低剩余 24 小时、SAN、服务端用途与系统可信链。RSA/ECC 都通过公钥 DER 比较,不使用仅适合 RSA 的 modulus 比较。

成功后将完整代次置于 releases 并替换一个 current 链接。证书 root:1883、0644;私钥 root:1883、0640;代次目录 root:1883、0750,让 Broker 可读但不能修改。数据目录由 1883 写入。源 ACME state/export 则保持 root 私有,不映射给 Broker。

原子切换的边界:一个 current 切换避免了两个普通目标文件先后覆盖的窗口,但文件系统不保证应用两次 open 为跨文件事务。本文要求只有本脚本发证书 HUP,人工 start/up/HUP 也在同一个 flock 下串行进行;不要另设 watchdog 在切换时并行 reload。若有外部服务管理器,必须协同锁或采用受控停启。主机掉电的强持久性还涉及 fsync/存储,不能把 rename 当成抗掉电事务。

运行中,脚本确认 Docker 状态,向 PID 1 发 HUP,随后从实际 published listener 握手验证可信链、域名和叶证书 SHA-256。只有验证通过且容器未换代,才写 applied 状态。applied 包含代次、容器 ID、StartedAt,防止容器重建后沿用旧确认。重复运行不重复发送 HUP,但会重新检查 TLS。TLS 探测只证明该 listener 上新连接证书有效,不能代替 MQTT 用户与 ACL 测试。

Mosquitto 2.0.22 文档明确 certfile 和 keyfile 在 HUP 时重新读取;listener 端口、protocol 等不是所有项目都可热更新。本文 HUP 只用于相同配置路径的证书刷新。已有 TLS 会话不通过一次 HUP 重新握手;要用新连接验证。若目标版本或插件不支持,安排维护窗口重启,会导致 MQTT 连接中断、重连和可能重复交付。固定分支 TLS 配置定义,Mosquitto 信号说明

5.3 单元和环境文件

/etc/systemd/system/mqtt-cert-deploy.path:

ini 复制代码
[Unit]
Description=Watch MQTT certificate installation notification
RequiresMountsFor=/opt/mqtt-stack/acme/certs/mqtt.example.com

[Path]
PathChanged=/opt/mqtt-stack/acme/certs/mqtt.example.com/.renewed
Unit=mqtt-cert-deploy.service

[Install]
WantedBy=multi-user.target

/etc/systemd/system/mqtt-cert-deploy.service:

ini 复制代码
[Unit]
Description=Validate and deploy MQTT certificate
RequiresMountsFor=/opt/mqtt-stack/acme/certs /opt/mqtt-stack/mosquitto/config/certs

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/deploy-mqtt-cert.sh
EnvironmentFile=-/etc/default/mqtt-cert-deploy
UMask=0077
TimeoutStartSec=180

/etc/systemd/system/mqtt-cert-deploy.timer:

ini 复制代码
[Unit]
Description=Reconcile missed MQTT certificate deployments

[Timer]
OnBootSec=2min
OnUnitInactiveSec=15min
Unit=mqtt-cert-deploy.service

[Install]
WantedBy=timers.target

timer 从开机和上次服务不活跃时安排复查,并非 ACME 的续期调度器。配置未启用 Persistent,因为这里采用单调时间计时,开机会另行补查。service 不设置 RemainAfterExit,正常执行后 inactive(dead) 是 oneshot 的正常状态;长期 active 会影响后续激活。

/etc/default/mqtt-cert-deploy:

dotenv 复制代码
# Source paths are configurable for an isolated lab.
DOMAIN=mqtt.example.com
SRC=/opt/mqtt-stack/acme/certs/mqtt.example.com
DST=/opt/mqtt-stack/mosquitto/config/certs
STATE=/var/lib/mqtt-cert-deploy
LOCK=/run/lock/mqtt-cert-deploy.lock
CONTAINER=mosquitto
BROKER_UID=1883
BROKER_GID=1883
CAFILE=/etc/ssl/certs/ca-certificates.crt
# Match the Compose host binding. If binding to a LAN IP, use that IP here.
PROBE_ADDR=127.0.0.1:8883

系统不是 Debian CA 路径时修改 CAFILE。PROBE_ADDR 应指向本机直连 Broker 的监听地址,不能填 CDN/反代或另一台 Broker;服务端 SAN 仍验证 DOMAIN。先检查磁盘部署、再检查 Docker 是刻意设计,不对 Docker 单元设置 Requires= 或主动 start,避免因续期而改变运行意图。

5.4 安装、第一次部署和启动

新实验部署从交付目录安装:

bash 复制代码
cd "$DELIVERY"
install -m 0750 配置附件/deploy-mqtt-cert.sh /usr/local/sbin/deploy-mqtt-cert.sh
install -m 0600 配置附件/mqtt-cert-deploy.env.example /etc/default/mqtt-cert-deploy
install -m 0644 配置附件/systemd/mqtt-cert-deploy.path /etc/systemd/system/
install -m 0644 配置附件/systemd/mqtt-cert-deploy.service /etc/systemd/system/
install -m 0644 配置附件/systemd/mqtt-cert-deploy.timer /etc/systemd/system/
bash -n /usr/local/sbin/deploy-mqtt-cert.sh
systemd-analyze verify /etc/systemd/system/mqtt-cert-deploy.{path,service,timer}
systemctl daemon-reload
systemctl enable --now mqtt-cert-deploy.path mqtt-cert-deploy.timer
systemctl start mqtt-cert-deploy.service
journalctl -u mqtt-cert-deploy.service -n 50 --no-pager

Docker daemon 正常、Broker 容器还没创建时,预期是磁盘准备完成 + CONTAINER_ABSENT,不是 TLS_VERIFIED。Docker 不可用时磁盘可能已部署,但 service 会失败,恢复后需重试。首次签发缺失或链校验失败时必须先修复,不要继续启动。

串行启动读取 current,并让服务再确认实际 TLS:

bash 复制代码
flock /run/lock/mqtt-cert-deploy.lock \
  docker compose -f /opt/mqtt-stack/mosquitto/compose.yaml \
  --env-file /opt/mqtt-stack/mosquitto/.env up -d
systemctl start mqtt-cert-deploy.service
docker compose -f /opt/mqtt-stack/mosquitto/compose.yaml ps
docker logs --tail=100 mosquitto
systemctl status mqtt-cert-deploy.path mqtt-cert-deploy.timer --no-pager
journalctl -u mqtt-cert-deploy.service -n 50 --no-pager

仅更改脚本内容不必 daemon-reload;更改单元需要 daemon-reload,修改 path 后还要 restart path,使新监听生效。内容有变的环境文件会在下次 service 执行读取。执行错误检查 ExecMainStatus 和 journal,不把 inactive(dead) 自动解释为失败。

6. 容器状态、幂等性和失败处理

图 4:证书磁盘部署完成后再判断运行状态。停止或不存在时不启动;Docker 不可用及验证失败会保留可重试状态。

场景 参考脚本行为 仍需确认
running + 新代次 校验证书、切 current、HUP、新 TLS 握手验证 MQTT 与 ACL 功能
running + 同代次/同启动实例 不再发 HUP,重新 TLS 验证 异常漂移仍报警退出
exited/created 更新磁盘,不 start 后续人工启动后 TLS
compose down/容器不存在 更新磁盘,记 ABSENT,不创建 容器后续重建
Docker daemon 不可用 磁盘校验部署可完成,运行步骤非零退出 daemon 恢复后 timer/手工重试
paused/restarting/dead/removing 不把它当正常 stopped,报错待处理 避免向不稳定容器错误确认
watcher 未启用/事件合并 timer 补偿;也可手工 start service 检查 watcher 是否恢复
ACME 签发/导出失败 不发布新代次标记,原服务继续当前证书 旧证书仍会到期,要告警
HUP/探测失败 不记录 applied,后续重试 current 已切换,先查日志;不盲目重启
TLS 新证书已生效 写入该容器启动实例的 applied 不能声称真实 CA 自动续期已验证

原脚本使用 docker ps 名称过滤,只考虑"运行/不在列表"两种结果;参考脚本区分 daemon 失败和容器不存在。停止状态保持仅指部署脚本不主动拉起;Docker 自己的 restart 策略、其他自动化、人工 up 是另一条运行控制链。

证书校验失败发生在 current 切换前,保留旧目标;HUP 后验证失败发生在切换后,磁盘可能已经是新代次。先确认链、挂载、权限和 Broker 日志。需要回滚时在同一锁下将 current 指向保留的已验证代次,再 HUP 并从客户端复验;同时修复源标记,否则 timer 会再次尝试新代次。不要自动回滚后仅打印"成功",隐藏续期部署故障。

7. TLS、MQTT、ACL 与续期验证

以下为目标服务器待执行验收命令。本次交付实际执行的检查范围见验收报告;没有伪造生产连接输出。

7.1 容器、挂载与证书

先看解析和服务状态:

bash 复制代码
cd /opt/mqtt-stack/mosquitto
docker compose config --quiet
docker compose ps
docker inspect mosquitto --format '{{json .Mounts}}'
docker exec mosquitto sh -c 'id; ls -l /mosquitto/config/certs/current/; test -r /mosquitto/config/certs/current/privkey.pem'
docker logs --tail=100 mosquitto
ss -lntp
systemctl status mqtt-cert-deploy.path mqtt-cert-deploy.timer --no-pager
journalctl -u mqtt-cert-deploy.service --since today --no-pager
openssl x509 -in config/certs/current/fullchain.pem -noout \
  -subject -issuer -serial -dates -ext subjectAltName -fingerprint -sha256

Docker 的发布有时走内核 NAT,ss 未列出 docker-proxy 不能单独判定端口未发布;综合 Compose ports、inspect、实际连接。不要用 cat 私钥做权限测试。

严格校验目标监听,地址按实际 binding 改:

bash 复制代码
openssl s_client -connect 127.0.0.1:8883 -servername mqtt.example.com \
  -verify_hostname mqtt.example.com -verify_return_error \
  -CAfile /etc/ssl/certs/ca-certificates.crt -showcerts < /dev/null

检查验证返回结果、叶证书 SAN/有效期和指纹;只有 -servername 是 SNI,并不等于主机名校验,所以还需 -verify_hostname。openssl s_client 默认行为可能在验证错误后继续,不能只看握手有输出就判成功。OpenSSL s_client

远程客户端使用实际域名连接;若 Broker 只在内网,设置局域 DNS 或受控 hosts,把域名解析到 LAN IP,而不是改用 IP 地址后禁用名称校验。证书只包含 DNS SAN 时直接用 IP 连接会不匹配。

7.2 不把密码放到进程参数

使用 Mosquitto 2.0 客户端的 XDG 默认配置文件机制。下面建立临时目录,通过交互密码写入 0600 配置,每行一个选项。测试结束删除。密码应不含换行;不在共享主机启用 shell trace。密码依然存在于受限文件和进程内存,不能说完全无凭据风险。2.0 客户端配置加载源码

在一台可以访问 Broker 的 Linux 测试机执行:

bash 复制代码
umask 077
MQTT_TEST_DIR=$(mktemp -d)
install -d -m 0700 "$MQTT_TEST_DIR/operator" "$MQTT_TEST_DIR/sensor01" "$MQTT_TEST_DIR/bad" "$MQTT_TEST_DIR/anon"
read -r -s -p "operator password: " MQTT_TEST_PASS; printf '\n'
printf '%s\n' '-h mqtt.example.com' '-p 8883' \
  '--cafile /etc/ssl/certs/ca-certificates.crt' '-V mqttv5' \
  '-u operator' "-P $MQTT_TEST_PASS" > "$MQTT_TEST_DIR/operator/mosquitto_sub"
cp "$MQTT_TEST_DIR/operator/mosquitto_sub" "$MQTT_TEST_DIR/operator/mosquitto_pub"
read -r -s -p "sensor01 password: " MQTT_TEST_PASS; printf '\n'
printf '%s\n' '-h mqtt.example.com' '-p 8883' \
  '--cafile /etc/ssl/certs/ca-certificates.crt' '-V mqttv5' \
  '-u sensor01' "-P $MQTT_TEST_PASS" > "$MQTT_TEST_DIR/sensor01/mosquitto_pub"
cp "$MQTT_TEST_DIR/sensor01/mosquitto_pub" "$MQTT_TEST_DIR/sensor01/mosquitto_sub"
unset MQTT_TEST_PASS
printf '%s\n' '-h mqtt.example.com' '-p 8883' \
  '--cafile /etc/ssl/certs/ca-certificates.crt' '-V mqttv5' \
  '-u sensor01' '-P INTENTIONALLY_WRONG_TEST_PASSWORD' > "$MQTT_TEST_DIR/bad/mosquitto_pub"

使用两个终端,第二个终端需得到同一个临时目录路径并设置 MQTT_TEST_DIR。先订阅再发布:

bash 复制代码
# 终端 A:operator 订阅遥测,收到一条后退出,20 秒超时
XDG_CONFIG_HOME="$MQTT_TEST_DIR/operator" mosquitto_sub \
  -t lab/sensor01/telemetry -q 1 -C 1 -W 20 -v

# 终端 B:sensor01 发布
XDG_CONFIG_HOME="$MQTT_TEST_DIR/sensor01" mosquitto_pub \
  -t lab/sensor01/telemetry -q 1 -m '{"temperature":23.5}'

# 错误密码:应被拒绝,记录退出码和 Broker 日志
XDG_CONFIG_HOME="$MQTT_TEST_DIR/bad" mosquitto_pub \
  -t lab/sensor01/telemetry -q 1 -m rejected

# 匿名连接:使用空 XDG 目录避免继承默认凭据,应被拒绝
XDG_CONFIG_HOME="$MQTT_TEST_DIR/anon" mosquitto_pub \
  -h mqtt.example.com -p 8883 --cafile /etc/ssl/certs/ca-certificates.crt \
  -V mqttv5 -t lab/sensor01/telemetry -q 1 -m rejected

# ACL 反例:sensor01 无权写 operator 的命令主题
XDG_CONFIG_HOME="$MQTT_TEST_DIR/sensor01" mosquitto_pub \
  -t lab/sensor01/command -q 1 -m forbidden -d

# ACL 反例:sensor01 无权读取遥测;查看 MQTT 5 SUBACK 与 Broker 日志
XDG_CONFIG_HOME="$MQTT_TEST_DIR/sensor01" mosquitto_sub \
  -t lab/sensor01/telemetry -q 1 -W 10 -d

同时测试正向命令流:sensor01 订阅 lab/sensor01/command,operator 发布该主题。负向写入测试必须确认 MQTT 5 PUBACK 原因/日志与授权订阅端未收到数据,不能仅以 TCP 连接成功或工具进程退出为准;部分订阅 ACL 体现为不投递,而非强制断开。MQTT 3.1.1 的拒绝反馈没有 MQTT 5 的原因码丰富,测试需分别观察。

测试后先退出订阅进程,再在本机检查临时目录位于此次 mktemp 路径,然后删除;不要对空变量或任意拼接路径递归 rm:

bash 复制代码
case "$MQTT_TEST_DIR" in
  /tmp/tmp.*) rm -rf -- "$MQTT_TEST_DIR" ;;
  *) printf 'Refuse unsafe cleanup path\n' >&2 ;;
esac
unset MQTT_TEST_DIR

7.3 按证据等级验证续期

测试 能证明什么 不能证明什么
bash -n / Compose config 语法/配置模型可解析 网络、挂载权限或 CA 可用
手工执行部署脚本 某次源快照的部署分支 watcher 与真实自动签发
systemctl start service oneshot 包装和执行环境 path 的触发
重写 .renewed 同一 ID 模拟事件触发,检查幂等 新证书签发或续期发生
acme.sh --cron 续期检查被执行 没到期时不一定续期
staging 隔离签发 CA 协议与 hook 实验 生产可信证书生效
真正到期前自动签发 + 发布 + HUP + 新握手 自动续期和服务加载链路 所有客户端/所有未来故障都无风险

模拟 marker 时读取已有合法 ID 并重新写入,不伪造一个不存在的代次:

bash 复制代码
IFS= read -r CERT_TEST_GEN < /opt/mqtt-stack/acme/certs/mqtt.example.com/.renewed
printf '%s\n' "$CERT_TEST_GEN" > /opt/mqtt-stack/acme/certs/mqtt.example.com/.renewed
journalctl -u mqtt-cert-deploy.service -n 50 --no-pager
systemctl list-timers mqtt-cert-deploy.timer --all

相同代次/同运行实例应不重复 HUP,仍做 TLS 复验。停止状态试验在维护窗口执行 compose stop,重写同 ID,再确认 exited、文件代次和日志;手动 start 后复验。down 测试会删除容器与网络,勿在有业务时随意执行。

手动续期检查不要与 daemon cron 并行。停止的是 ACME,MQTT 持续运行:

bash 复制代码
cd /opt/mqtt-stack/acme
docker compose stop acme
docker compose run --rm acme acme.sh --cron --config-home /acme.sh
# 不论上述检查是否成功都要恢复 daemon,并保留失败记录
docker compose up -d acme
docker compose logs --tail=100 acme

如用 set -e 自动执行此段,要通过 trap 或明确的 finally 机制保证恢复 daemon。不要在生产频繁 --force,避免 CA 限额;强制实验用隔离 staging state/export。真实验收记录签发前后的 serial/notAfter/指纹、ACME 日志、marker ID、systemd 时间、DISK_DEPLOYED/HUP_SENT/TLS_VERIFIED 和新连接远端指纹。只有这一条证据链才支持"自动续期已实际生效"。

8. 安全加固、故障排查和运维

8.1 安全操作

给每台设备独立凭据,使用足够强且不复用的密码;离线/丢失设备撤销密码和 ACL。password_file 重载不一定终止既有已认证连接,紧急撤权还需针对会话断开或维护重启。不要把 retained 命令当成无条件安全的控制消息,设备要验证命令时效和业务身份。

主机管理员、Docker socket 和 docker 组通常具备高度控制能力,ACME 不挂 Docker socket;Token 只管理指定 DNS zone,凭据文件和 state 不进 Git。私钥泄露后重新生成密钥、申请新证书、部署复验并向 CA 吊销旧证书,同时调查泄露源;仅 renew 且复用旧密钥不足以解决泄露。

公网暴露前从外部实测所有 IPv4/IPv6 入口。Docker 发布端口可能绕过某些前端防火墙工具的预期路径;按 Docker 官方防火墙后端集成限制来源,别仅写一条 ufw 规则就宣称隔离完成。Docker 与防火墙

监控至少包括证书剩余寿命、ACME daemon 是否运行、path/timer 是否 active、部署最近成功时间、MQTT 实际连接与 ACL。日志正常保留需脱敏,长期 debug 可能包含主题或消息,不应公开上传完整凭据环境。到期阈值应按所选 profile 的寿命和运维响应时间设置,而不是机械固定为 30 天。

8.2 故障排查表

按网络 → TLS → MQTT 认证 → ACL → 消息语义顺序排查。缩写:M=/opt/mqtt-stack/mosquitto,A=/opt/mqtt-stack/acme;下表命令中展开完整路径或先 cd。

现象 常见原因 检查 修复与复验
Broker 启动失败 配置拼写、证书缺失、端口冲突 docker logs mosquitto;ss -lntp 修正明确错误;重启后做 TLS+MQTT
conf 语法错误 不支持选项/版本混用 logs;镜像版本 依据固定分支文档修正,不套用最新参数
证书找不到 current 未部署、挂载错路径 inspect .Mounts;exec ls 首次部署成功,再验证容器路径
私钥 Permission denied UID 映射、父目录缺 x 权限 exec id/test -r;namei -l 修正父目录与组权限,不改 777
key values mismatch 复制半更新或源不配对 pkey 公钥与 x509 公钥比较 不切换无效代次,重新导出
TLS 握手失败 明文连 TLS、TLS 最低版本不匹配 s_client;Broker logs 正确 TLS 客户端,升级旧设备或隔离网关
域名不匹配 用 IP、错误 SAN/DNS x509 -ext subjectAltName;verify_hostname 使用证书域名并正确解析,重签正确 SAN
链不可信/不完整 只装 leaf、过期 CA 包、staging s_client -showcerts;openssl verify 装 fullchain,更新可信根,勿关闭校验
认证失败 用户缺失、密码错、默认客户端配置污染 passwords 文件存在;日志;空 XDG 测试 更新正确账户,重载后新连接复验
ACL 拒绝 username 不符、主题大小写/层级错 ACL 与 MQTT5 原因码 按最小权限补确切主题并验证拒绝反例
已连接却无消息 未先订阅、无 retain、ACL、QoS/会话参数 pub/sub -d;检查主题 先订阅再发布,区分 retain 与历史消息
ACME 申请失败 Token 权限、CAA、账户、CA 限额 ACME logs;CAA/DNS 查询 修复根因并退避,不循环 --force
DNS-01 失败 TXT 不可见、错 zone、传播延迟 dig TXT _acme-challenge.域名 查询权威 DNS;修正插件和权限再试
未自动续期 daemon 停、state 丢失、检查未到续期点 compose ps/logs;证书 dates 恢复调度持久化,区分 check 与 renew
marker 无变化 未注册 reloadcmd、导出/hook 失败 install-cert 参数与日志 修复后再 install-cert,检查合法代次
.renewed 未触发 watcher 未开、监听其他路径、NFS systemctl status;journal;模拟重写 修正路径,restart path,用 timer 补偿
service 失败 缺 openssl、可信链错、Docker down journal;ExecMainStatus 按 ERROR 修复,手工 start 复验
HUP 发出但仍旧证书 PID1 包装、单文件挂载、错误 listener inspect;logs;客户端指纹 改整体目录挂载、确认信号路径;必要时维护重启
更新后容器异常 磁盘权限、非兼容证书/插件 logs;前后代次 保留旧证书回滚并复验,不直接拉起停止容器
容器停止时更新 正常设计,也可能其他自动化拉起 docker inspect 状态;脚本日志 确认只磁盘部署;查 restart 策略/外部管理器
探测失败但公网可连 本机 PROBE_ADDR 与 binding 不符 Compose ports;s_client 本机地址 修正探测地址,保留 DOMAIN/SNI 校验
发布锁残留 hook 被 SIGKILL/宿主机异常 无 ACME 写入进程后检查锁 人工清理已确认残留,重新导出发布

8.3 日常维护与升级

日常查看状态,不把日志中"Reload successful"(ACME hook)当成 Broker 已确认:

bash 复制代码
docker compose -f /opt/mqtt-stack/mosquitto/compose.yaml ps
docker compose -f /opt/mqtt-stack/acme/compose.yaml ps
docker logs --tail=100 mosquitto
docker logs --tail=100 acme
journalctl -u mqtt-cert-deploy.service --since today --no-pager
systemctl list-timers mqtt-cert-deploy.timer --all
openssl x509 -in /opt/mqtt-stack/mosquitto/config/certs/current/fullchain.pem -noout -dates
systemctl start mqtt-cert-deploy.service

明确操作意图:stop 保留容器;start 启动已有容器;down 删除容器/网络但 bind 目录仍在;up 可创建/重建并挂回目录。需要重启时先通知连接中断,客户端使用退避重连和业务去重。

升级前备份 .env、Compose、conf、ACL、passwords、data、证书代次和 ACME state(加密存放,权限保护)。持久化一致性备份应安排短暂停机,正常退出完成保存后复制 data;在线 tar 不能保证数据库一致性。ACME state 包含账户私钥和 Token,不可作为公开附件。

选择经过审查的新标签或 digest,修改 .env 后:

bash 复制代码
cd /opt/mqtt-stack/mosquitto
docker compose config --quiet
docker compose pull
flock /run/lock/mqtt-cert-deploy.lock docker compose up -d
systemctl start mqtt-cert-deploy.service
docker compose logs --tail=100 mosquitto

up 可能重建容器,客户端会断连;pull 本身不修改当前运行容器。若使用固定 digest,pull 同一 digest 不会自动选择新版。ACME 升级也先备份账户状态并固定新 digest,复验 DNS 插件、安装 hook、cron 与工具能力。

不要无限保留 releases 里的私钥,也不要在部署中随意删代次。维护窗口先确认没有执行中部署,备份后只保留当前、必要回滚与未完成状态需要的代次。可选告警应报告失败、到期余量和长时间未确认,避免每 15 分钟正常复查都发通知。

相关推荐
Initialize-le4 小时前
Nginx 504 超时、499 一堆?网关排查法
运维·nginx
Android系统攻城狮6 小时前
Linux Gstreamer深度解析之gst_audio_ring_buffer_delay调用流程与实战(七十二)
linux·运维·服务器·gstreamer音视频·音视频进阶
快乐的韭菜根根7 小时前
ubuntu 安装slurm并配置
linux·运维·ubuntu
U-Mail邮件系统7 小时前
自建邮箱服务器有哪些软件?三款主流邮件服务器软件推荐
运维·服务器
空空dream7 小时前
03 | 离线部署 FastAPI + Ollama(qwen2:0.5b):从 Docker 联调到 Kubernetes 验收
docker·ai·kubernetes·fastapi
天远数科9 小时前
零信任架构实战:基于天远贷前风险报告构建自动化消金信贷风控与多头共债排查网关
运维·人工智能·架构·自动化
别动我齐刘海10 小时前
Linux 系统调试学习2
linux·运维·c++·人工智能·学习·机器学习·机器人
honsor10 小时前
机房IP化改造首选:以太网温湿度传感器,PoE供电+Modbus TCP一网搞定
linux·运维·服务器
星创易联10 小时前
PLC数据采集与远程运维平台:从边缘计算到集中监控
大数据·运维·边缘计算