前言
当我们有多台云服务器,并且每台服务器都托管着同一主域名下不同子域名的站点时,就会面临一个共同问题:
如何在只申请一次通配符 SSL 证书的情况下,让所有服务器都自动获得最新的证书?
比如:
- 主服务器:
www.mydomain.com - 服务器 A:
aaa.mydomain.com - 服务器 B:
bbb.mydomain.com - 服务器 C:
ccc.mydomain.com
我们希望只在 主服务器 上通过 acme.sh 自动申请和续签 *.mydomain.com 的通配符证书,然后自动把证书分发到 A、B、C 等服务器上。当然,可以每个服务器都安装 acme.sh,然后让它们各自申请各自服务器上使用的证书也是可以的。不过,这么做感觉上有些混乱。
整体思路
- 考虑到权限与可操作性,主服务器使用 root,目标服务器使用普通用户。
- 选择一台最安全可靠的服务器(主服务器)作为唯一负责证书申请与续签的节点。
- 所有其他服务器(例如 A、B、C)只接收主服务器同步过去的证书文件。
- 主服务器通过
acme.sh自动续签证书后,能自动触发一个同步脚本(sync_certs_to_target.sh)。 - 这个脚本要做的是使用
ssh和scp登录其他服务器并上传证书同时设置好权限,然后在目标服务器上执行nginx reload。
这样,只需要在主服务器上完成一次配置,所有服务器的 HTTPS 证书都能自动保持最新。
一、在主服务器上生成密钥对
ssh-keygen -t ed25519 -C "cert-sync" # -C 后面引号里的是注释,可以再详细一些,好记性不如烂笔头
运行上面命令后会提示输入密钥保存位置以及是否需要给密钥额外加一个口令,因为我们要的是在一台服务器上登录另外一台服务器,所以就不需要加密钥的口令了,基本上没有这个必要。一直按回车(Enter)就行,最终会在 /home/user/.ssh/ 下面生成私钥和公钥文件(id_ed25519 和 id_ed25519.pub)。
注意,这里我一开始在主服务器上用的也是普通用户来生成的密钥对,但是后来发现想完全用普通用户来登录目标服务器并完成所有操作有些困难,因为牵扯到好多权限问题,不太好调和,主要是因为我的 acme.sh 本身是 root 用户运行的
然后找到 ~/.ssh 目录下面的公钥 id_ed25519.pub,复制它的内容到我们的目标服务器,Linux 里面基本都是同样的目录 /home/user/.ssh,这个目录下面的 authorized_keys 文件里面,新购买的服务器可能没有,新建一个这个名称文件就可以了(注意,这个文件的权限应该是 600)。
然后回到主服务器测试登录目标服务器:
ssh -p ** user@ip # 这里加了 -p 是因为我的端口不是默认的 22,如果默认端口,直接 ssh user@ip 就可以了
如果一切正常,运行上面的登录命令就可以在主服务器上面登录到目标服务器。(首次登录时会看到熟悉的提示 This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])?,选择 yes 就可以了。)
二、配置自动同步 SSL 证书的脚本
简单的概括,这个脚本要完成的任务是在它被 acme.sh 调用后,把 acme.sh 刚刚生成或者更新的 SSL 证书复制到目标服务器并完成权限设定和重载目标服务器的 webserver 服务器比如 nginx。
这里看似简单,实际在测试过程中,我还是饶了很多弯路的。原因嘛,当然是因为权限问题。
我的理想情况是无论主服务器还是目标服务器都是用普通用户(可以 sudo)来完成整个密钥登录以及证书同步的流程,可是实际操作时发现这牵扯到各种权限制约,很不方便。所以我最终选择了主服务器使用 root 来运行脚本,也就是在主服务器上用 root 来登录目标服务器的普通用户。
我目前使用的同步脚本(Gemini 写的)如下:
#!/bin/bash
# --- 配置区 ---
TARGET_USER="user" # 目标服务器用户名
TARGET_HOST="111.222.333.444" # 目标服务器地址
TARGET_HOST_PORT="1234" # 目标服务器端口
REMOTE_CERT_DIR="/etc/ssl/nginx/mydomain.com" # 目标服务器上存放证书的目录
REMOTE_KEY_DIR="/etc/ssl/nginx/mydomain.com" # 目标服务器上存放私钥的目录
REMOTE_RELOAD_CMD="sudo /usr/bin/systemctl reload nginx" # 目标服务器上的 nginx 重载命令
TEMP_DIR="/tmp/cert_sync" # 目标服务器临时目录
# --- 参数检查 ---
DOMAIN=$1
if [ -z "$DOMAIN" ]; then
echo "[CertSync][ERROR] 必须提供域名作为参数。"
exit 1
fi
# acme.sh 默认证书路径(注意:这是 root 下的路径)
ACME_CERT_PATH="/root/.acme.sh/${DOMAIN}_ecc"
# 检查证书文件是否存在
if [ ! -f "${ACME_CERT_PATH}/fullchain.cer" ] || [ ! -f "${ACME_CERT_PATH}/${DOMAIN}.key" ]; then
echo "[CertSync][ERROR] 找不到证书文件,请检查路径:${ACME_CERT_PATH}"
exit 1
fi
LOG_PREFIX="[CertSync][${DOMAIN}]"
echo "${LOG_PREFIX} 开始同步到 ${TARGET_HOST} ..."
# --- 1. 确保目标服务器临时目录存在 ---
ssh -p ${TARGET_HOST_PORT} ${TARGET_USER}@${TARGET_HOST} "mkdir -p ${TEMP_DIR}"
if [ $? -ne 0 ]; then
echo "${LOG_PREFIX} 错误: 创建临时目录失败!"
exit 1
fi
# --- 2. 传输 fullchain.cer 到临时目录 ---
echo "${LOG_PREFIX} 正在上传 fullchain.cer 到临时目录 ..."
scp -P ${TARGET_HOST_PORT} -q "${ACME_CERT_PATH}/fullchain.cer" ${TARGET_USER}@${TARGET_HOST}:${TEMP_DIR}/cert.pem
if [ $? -ne 0 ]; then
echo "${LOG_PREFIX} 错误: 上传 fullchain.cer 失败!"
exit 1
fi
# --- 3. 传输 key 到临时目录 ---
echo "${LOG_PREFIX} 正在上传 ${DOMAIN}.key 到临时目录 ..."
scp -P ${TARGET_HOST_PORT} -q "${ACME_CERT_PATH}/${DOMAIN}.key" ${TARGET_USER}@${TARGET_HOST}:${TEMP_DIR}/key.pem
if [ $? -ne 0 ]; then
echo "${LOG_PREFIX} 错误: 上传 ${DOMAIN}.key 失败!"
exit 1
fi
# --- 4. 移动文件到最终目录并设置权限 ---
echo "${LOG_PREFIX} 移动文件到最终目录并设置权限 ..."
ssh -p ${TARGET_HOST_PORT} ${TARGET_USER}@${TARGET_HOST} "\
sudo mv ${TEMP_DIR}/cert.pem ${REMOTE_CERT_DIR}/cert.pem && \
sudo mv ${TEMP_DIR}/key.pem ${REMOTE_KEY_DIR}/key.pem && \
sudo chown root:root ${REMOTE_CERT_DIR}/cert.pem && \
sudo chown root:root ${REMOTE_KEY_DIR}/key.pem &&\
sudo chmod 644 ${REMOTE_CERT_DIR}/cert.pem && \
sudo chmod 600 ${REMOTE_KEY_DIR}/key.pem \
"
if [ $? -ne 0 ]; then
echo "${LOG_PREFIX} 错误: 移动文件或设置权限失败!"
exit 1
fi
# --- 5. 触发远程 nginx reload ---
echo "${LOG_PREFIX} 正在重载远程 nginx ..."
ssh -p ${TARGET_HOST_PORT} ${TARGET_USER}@${TARGET_HOST} "${REMOTE_RELOAD_CMD}"
if [ $? -ne 0 ]; then
echo "${LOG_PREFIX} 错误: 远程 nginx reload 失败!"
exit 1
fi
echo "${LOG_PREFIX} 同步完成,nginx 已成功重载!"
exit 0
上面的脚本是我提出要求后让 Gemini 帮忙写的,里面添加了很多验证以及日志输出,总体逻辑是没有问题的,也可以正常运行。
这里额外说明一点,我发现对于 IPv6 地址,scp 命令 和 ssh 命令对待不一致,scp 要求把地址使用 [] 号括起来,可是如果括起来,ssh 命令又识别不了。所以我选择了使用 IPv4 地址来登录目标服务器,当然如果非要用也没有多难,在脚本里加一个判断来处理一下即可。
三、让 acme.sh 在每次申请或者更新证书后自动触发同步脚本的运行
接下来的任务是怎么能让 acme.sh 在初次申请证书后或者例行更新证书后自动触发执行上面的同步脚本。
acme.sh 有一个 --reloadcmd 参数,在它后面可以添加在安装完证书后需要重载的某些服务的重载命令,最典型的比如 systemctl reload nginx,因为如果不重载,nginx 会依然使用旧证书,那么等到旧证书过期后就会出错,站点就打不开了。因为 acme.sh 会自动更新证书,所以其它需要读取证书的服务也得在证书更新并安装到位后知道证书已经更新了,那么理所当然,把需要重载的服务的重载命令加上就可以了。
那么上一步写的同步脚本,是否也加到 --reloadcmd 命令里面呢?从可执行性上来说,写上是没有问题的,但是从逻辑上来讲,这么做是不合适的。acme.sh 有一个命令是 --renew-hook,
未完待续~~~