Files
HomeDash/deploy/cert-agent.sh
JianFeeeee 12c07034a0 fix(cert): cert-agent 真实签发验证后修的三处问题
在测试容器里用真实凭据跑通了完整签发(staging CA),暴露三个真问题:

## ① CERT_DNS_CRED 没导出成 acme.sh 期望的变量名(致命)
脚本只把凭据解析出插件名 dns_ali,却从没导出 Ali_Key/Ali_Secret。
acme.sh 的 dns_ali.sh 读的是 $Ali_Key/$Ali_Secret,所以 DNS 步骤
报 'Error adding TXT record',而 ACME 其它步骤全部正常 ——
极易误判成"凭据无效/网络问题"。**stub 测试完全没暴露这个**,
只有真跑才看得见。现改为把 CERT_DNS_CRED 按逗号拆开逐个 export。

## ② 镜像内置 acme.sh,不再运行时下载
installer 内部从 raw.githubusercontent.com 取源码,而该域名境内直连超时
(实测直连 8.2s 失败 / 经 .60:7890 代理 0.53s 成功),
首次启动会静默失败、之后永远起不来。Dockerfile 新增 acmebuild stage
内置 acme.sh v3.1.6,运行时安装降级为兜底。

## ③ 下载重试 + 安装后校验
容器出网偶发 Connection reset by peer(同一 URL 隔几秒即 200),
加了 3 次重试与「安装后确认可执行」,避免半截状态。

## 真实测试证据(staging CA,零正式配额消耗)
TXT 写入 → Verifying → Success → Successfully removed → Cert success;
签出 CN=dashy.jianfgit.xyz(Let's Encrypt YE1,90 天);
Le_ReloadCmd 已写入 ⇒ 续期后自动 nginx -t && nginx -s reload。

测试环境已清理(含凭据文件),线上 v106 未受影响。
2026-10-04 08:29:35 +08:00

206 lines
9.8 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/sh
# HomeDash cert-agent:容器内自动续签「自己的」证书并让 nginx 生效。
#
# ── 为什么放在容器里(用户决策:self-only + full-acme)────────────────
# 此前 HomeDash 的证书由外部链路签发并推送进来:
# .106(acme.sh 真源) → .60(sync-cert.sh 每日) → 云主机(cert-sync.timer 每小时)
# 但那条链路只服务云主机那一层的入口;HomeDash 自己是**只读**挂载
# /etc/nginx/certs(RW=false),续期后没人告诉它的 nginx 重新握手。
# 本 agent 让 HomeDash 自己闭环:签发 → 写数据卷 → reload nginx。
#
# 它只管 HomeDash 自己这张证书(gateway.yml 里的 certFile/keyFile),
# **不接管**其它站点(introduce/sdk.homeagent 等仍走外部链路)。
#
# ── 为什么证书写数据卷而不是挂载目录 ─────────────────────────────────
# /etc/nginx/certs 是只读挂载,容器内写不进去。数据卷 /app/data 是可写的,
# 且 gateway.yml(本来就在数据卷)可以把 certFile 指向 /app/data/certs/。
# 这样无需改动挂载方式、不动镜像里的证书文件,也不影响已有部署。
#
# ── 凭据 ──────────────────────────────────────────────────────────────
# DNS API 凭据只从环境变量读,**不落盘、不入库**(AGENTS.md §6):
# CERT_DNS_API 逗号分隔的插件名,如 dns_ali
# CERT_DNS_CRED 对应凭据,如 Ali_Key=xxx,Ali_Secret=yyy
# CERT_ACME_CA 可选,letsencrypt(默认) 或 zerossl
# 缺凭据时本进程**安静退出**(不刷错误日志),入口仍可用外部链路提供的证书。
#
# ── 运行 ──────────────────────────────────────────────────────────────
# 由 supervisord 以 [program:cert-agent] 拉起,一个 sleep 循环(默认 12h)
# 调一次 acme.sh --cron。ACME_HOME 落在数据卷,容器重建不丢续期状态。
set -u
LOG=/dev/stdout
HOME_DIR=/app/data/acme
CERT_DIR=/app/data/certs
CONF=/app/data/gateway.yml
INTERVAL="${CERT_RENEW_INTERVAL:-43200}" # 秒,默认 12h
say() { echo "[cert-agent] $*" >>"$LOG"; }
# ── 0. 未配置凭据就安静退出:没有它本脚本无事可做,也不该刷日志 ──
if [ -z "${CERT_DNS_API:-}" ] || [ -z "${CERT_DNS_CRED:-}" ]; then
say "未配置 CERT_DNS_API/CERT_DNS_CRED,本容器不自动续签(证书由外部链路提供)"
exit 0
fi
[ -d "$HOME_DIR" ] || mkdir -p "$HOME_DIR"
[ -d "$CERT_DIR" ] || mkdir -p "$CERT_DIR"
# ── 1. 定位 acme.sh:镜像内置优先,运行时安装是兜底 ─────────────────
# 为什么不运行时安装:installer 内部从 raw.githubusercontent.com 取 acme.sh,
# 而该域名在境内直连会超时(实测直连 8.2s 失败、经 .60:7890 代理 0.53s 成功),
# 于是「首次启动装 acme.sh」这一步会静默失败,之后永远起不来。
# 因此镜像里内置一份(Dockerfile 的 acmebuild stage),运行时只剩兜底路径。
BUNDLED=/usr/local/share/acme.sh
ACME="$HOME_DIR/acme.sh"
if [ -x "$BUNDLED/acme.sh" ]; then
# 拷贝到数据卷:acme.sh 会在自己所在目录写 ca/ dnsapi/ 与证书状态,
# 放在只读的镜像层里会写失败。
mkdir -p "$HOME_DIR"
cp -a "$BUNDLED/." "$HOME_DIR/" 2>/dev/null || true
[ -x "$ACME" ] || say "内置 acme.sh 拷贝失败,落到运行时安装路径"
fi
if [ ! -x "$ACME" ]; then
say "镜像内置 acme.sh 不可用,尝试运行时安装(需要能访问 raw.githubusercontent.com)"
# 兜底:需要代理时设 CERT_ACME_PROXY(容器内直连不通的环境)。
PROXY_OPT=""
[ -n "${CERT_ACME_PROXY:-}" ] && PROXY_OPT="-x $CERT_ACME_PROXY"
INSTALLER="$HOME_DIR/acme.sh.install"
OK=0
for i in 1 2 3; do
# shellcheck disable=SC2086 # PROXY_OPT 需按空格拆开
if curl -fsSL -m 120 $PROXY_OPT https://get.acme.sh -o "$INSTALLER" 2>>"$LOG"; then
OK=1; break
fi
say "下载 get.acme.sh 第 $i 次失败,重试"
sleep $((i * 5))
done
if [ "$OK" = "1" ]; then
sh "$INSTALLER" --home "$HOME_DIR" --nocron >>"$LOG" 2>&1 || say "安装 acme.sh 失败"
rm -f "$INSTALLER"
else
say "下载失败,跳过本次(下次循环重试)"
exit 0
fi
[ -x "$ACME" ] || { say "安装后仍找不到 $ACME,跳过本次"; exit 0; }
fi
# ── 2. 从 conf/gateway.yml 读出要签的域名 ────────────────────────────
# certDomains 是显式配置;没配就从 gateway.yml 的 routes 里取 host。
# 只签 HomeDash 自己的域名,不含通配符之外的推测。
DOMAINS="${CERT_DOMAINS:-}"
if [ -z "$DOMAINS" ] && [ -f "$CONF" ]; then
DOMAINS=$(grep -E '^[[:space:]]*-?[[:space:]]*host:' "$CONF" 2>/dev/null \
| sed -E 's/.*host:[[:space:]]*//; s/["'"'"']//g' \
| grep -vE '^\*|^_$' | sort -u | tr '\n' ' ')
fi
DOMAINS="$(echo "$DOMAINS" | xargs)"
if [ -z "$DOMAINS" ]; then
say "未能确定要签发的域名(设 CERT_DOMAINS 或检查 $CONF),本次跳过"
exit 0
fi
say "目标域名: $DOMAINS"
# ── 3. 记录当前生效证书的指纹,用于判断续期后要不要 reload ──────────
cur_fp() { # <证书路径>
[ -s "$1" ] || { echo ""; return; }
openssl x509 -in "$1" -noout -pubkey 2>/dev/null | openssl md5 | awk '{print $NF}'
}
CERT_OUT="$CERT_DIR/fullchain.pem"
KEY_OUT="$CERT_DIR/privkey.pem"
BEFORE="$(cur_fp "$CERT_OUT")"
# ── 4. 把 DNS 凭据交给 acme.sh ──────────────────────────────────────
# --dns 由 CERT_DNS_CRED 里出现的键名决定(dns_ali / dns_cf / dns_dp …)
DNS_PLUGIN=""
case "$CERT_DNS_CRED" in
Ali_Key=*|*Ali_Key=*) DNS_PLUGIN="dns_ali" ;;
CF_Token=*|CF_DNS_API_Token=*) DNS_PLUGIN="dns_cf" ;;
*) [ -n "$CERT_DNS_API" ] && DNS_PLUGIN="$CERT_DNS_API" ;;
esac
[ -z "$DNS_PLUGIN" ] && { say "无法从 CERT_DNS_CRED 判断 DNS 插件,本次跳过"; exit 0; }
CA="letsencrypt"
[ -n "${CERT_ACME_CA:-}" ] && CA="$CERT_ACME_CA"
# ★ 必须把 CERT_DNS_CRED 里的键值**导出成真实环境变量名**,acme.sh 的
# dns_ali.sh 读的是 $Ali_Key / $Ali_Secret(不是我们的 CERT_DNS_CRED)。
# 只挑出插件名而不导出凭据的表现是:DNS 步骤报
# "Error adding TXT record to domain",而 ACME 其它步骤都正常 ——
# 很容易误判成"凭据无效/网络问题"。
# 用 set -a? 不行(要过滤)。这里显式 eval 展开赋值。
save_ifs="$IFS"
IFS=','
for kv in $CERT_DNS_CRED; do
k="${kv%%=*}"
v="${kv#*=}"
case "$k" in
''|*[!A-Za-z0-9_]*) ;; # 跳过空项与非法变量名
*) export "$k=$v" ;;
esac
done
IFS="$save_ifs"
run_acme() {
# 拼出 -d domain 列表。刻意不用 `set -- $DOMAINS`:那会改掉函数的位置参数。
DFLAGS=""
for d in $DOMAINS; do DFLAGS="$DFLAGS -d $d"; done
# shellcheck disable=SC2086 # DFLAGS 需按空格拆成多个参数
"$ACME" --issue --dns "$DNS_PLUGIN" $DFLAGS --ecc --server "$CA" --home "$HOME_DIR" \
--dnssleep 120 >>"$LOG" 2>&1
}
# 首次签发 or 已到期才需要重签;acme.sh 自己判断续期窗口(--cron 语义)
issue_if_needed() {
# 已安装过该域名 → 交给 acme.sh 判断是否到续期点
if "$ACME" --list --home "$HOME_DIR" 2>/dev/null | grep -q "$(echo "$DOMAINS" | cut -d' ' -f1)"; then
say "已存在证书,交给 acme.sh 判断续期窗口"
"$ACME" --renew -d "$(echo "$DOMAINS" | cut -d' ' -f1)" --ecc --home "$HOME_DIR" >>"$LOG" 2>&1
else
say "首次签发:$DOMAINS"
run_acme
fi
}
issue_if_needed
# ── 5. 把证书装到数据卷 ─────────────────────────────────────────────
# --install-cert 让 acme.sh 把 cert/key 落到数据卷,并在**每次续期后**自动
# 重跑这个 --install-cert(acme.sh 会把它写进 Le_ReloadCmd),从而续期即生效。
#
# reload 直接用 nginx -s reload,不走 admin-server 的 /api/gateway/apply:
# 那个接口需要 admin token(容器内拿不到),而 /api/reload 是个空壳
# (只返回 {"status":"saved"},并不真的重载,见 admin-server/main.go:1338)。
# 证书路径没变,只是文件内容换了,所以不需要重生成 gateway.conf。
# 注意:这是命令串,必须用 eval 执行;直接 `LE_RELOAD` 会被当成单个命令名,
# 在 dash 下报 "LE_RELOAD: not found",容器里 reload 会静默失效。
LE_RELOAD_CMD="nginx -t && nginx -s reload"
le_reload() { eval "$LE_RELOAD_CMD"; }
FIRST_D=$(echo "$DOMAINS" | cut -d' ' -f1)
"$ACME" --install-cert -d "$FIRST_D" --ecc --home "$HOME_DIR" \
--fullchain-file "$CERT_OUT" --key-file "$KEY_OUT" \
--reloadcmd "$LE_RELOAD_CMD" >>"$LOG" 2>&1 || say "install-cert 失败(下次循环重试)"
# ── 6. 指纹变了就 reload nginx(让新证书立刻被握手使用)────────────
AFTER="$(cur_fp "$CERT_OUT")"
if [ -n "$AFTER" ] && [ "$AFTER" != "$BEFORE" ]; then
say "证书已更新($BEFORE → $AFTER),reload nginx"
le_reload
else
say "证书未变化,无需 reload"
fi
# ── 7. 循环 ──────────────────────────────────────────────────────────
while :; do
sleep "$INTERVAL"
say "=== 周期检查开始 ==="
issue_if_needed
AFTER="$(cur_fp "$CERT_OUT")"
if [ -n "$AFTER" ] && [ "$AFTER" != "$BEFORE" ]; then
say "证书已更新,reload nginx"
le_reload
BEFORE="$AFTER"
fi
done