Files
HomeDash/deploy/cert-agent.sh
JianFeeeee 96e771d546 feat(cert): cert-agent 支持多证书组 + 多目标推送(全量接管)
用户要求「部署时接管全部证书」。原来只签一组(self-only)。

## 多证书组(按证书而非按域名组织)
CERT_GROUPS 每行 '<组名>:<域名1,域名2,...>',例如:
  wild:*.jianfgit.xyz,jianfgit.xyz
  introduce:introduce.homeagent.jianfgit.xyz
  sdk:sdk.homeagent.jianfgit.xyz
每组独立落到 /app/data/certs/<组名>/,签发/续期/推送都以证书为单位
(同一张证书覆盖多个 SAN)。不配置时回落 gateway.yml 的 host(旧行为)。

## 多目标推送
CERT_PUSH_TARGETS 每行 '<host>:<远端cert>:<远端key>:<reload 命令>',
把证书推到其它机器(云主机 SafeLine 等)并触发其 reload。
认证走 CERT_PUSH_SSH_KEY / CERT_PUSH_SSH_PASS,都没有则免密。

## 途中修掉的 bug
① for 循环按**空格**分词,把含空格的 reload 命令("nginx -s reload")
   连同整行目标拆成 host/"-s"/"reload" 三片,产生三条无意义推送。
   改为 IFS=换行 + for,恢复 IFS。
② 凭据检查要求 CERT_DNS_API 与 CERT_DNS_CRED 同时存在,但插件名本来
   就能按凭据键名推断 —— 只需 CERT_DNS_CRED。
③ 改造时误删 LE_RELOAD_CMD / le_reload 定义(set -u 下直接
   "parameter not set" 中断)。已补回。

## 测试
沙箱 stub 覆盖:三组各自签发、通配符 SAN 正确、产出目录结构完整、
推送四步(mkdir→写cert→写key+chmod600→reload)全部执行。
真实签发此前已用 staging CA 验证通过(CN=dashy…, LE_ReloadCmd 已写入)。
2026-10-04 08:52:47 +08:00

309 lines
14 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. 未配置凭据就安静退出:没有它本脚本无事可做,也不该刷日志 ──
# 只要有 CERT_DNS_CRED 就够 —— DNS 插件名可以按凭据里的键名推断
# (Ali_* → dns_ali、CF_* → dns_cf…),不必强制再写一遍 CERT_DNS_API。
if [ -z "${CERT_DNS_CRED:-}" ]; then
say "未配置 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. 确定要签发的证书组 ─────────────────────────────────────────────
#
# 两种来源,可共存(显式优先):
# CERT_GROUPS 每行一组: <组名>:<域名1,域名2,...>
# 同一张证书常覆盖多个域名(SAN),且签发/续期/推送都以
# **证书**为单位,所以按"组"而不是按单个域名组织。
# 例:
# CERT_GROUPS="wild:*.jianfgit.xyz,jianfgit.xyz
# introduce:introduce.homeagent.jianfgit.xyz
# sdk:sdk.homeagent.jianfgit.xyz"
# CERT_DOMAINS 逗号分隔(简化写法,等价于一个名为 default 的组)
#
# 都没配时回落到 gateway.yml 的 host(旧的 self-only 行为),
# 保证不配置也不会坏。
GROUPS=""
if [ -n "${CERT_GROUPS:-}" ]; then
GROUPS="$CERT_GROUPS"
elif [ -n "${CERT_DOMAINS:-}" ]; then
GROUPS="default:$CERT_DOMAINS"
elif [ -f "$CONF" ]; then
_d=$(grep -E '^[[:space:]]*-?[[:space:]]*host:' "$CONF" 2>/dev/null \
| sed -E 's/.*host:[[:space:]]*//; s/["'"'"']//g' \
| grep -vE '^_$' | sort -u | tr '\n' ',' | sed 's/,$//')
[ -n "$_d" ] && GROUPS="default:$_d"
fi
if [ -z "$GROUPS" ]; then
say "未确定要签发的域名(设 CERT_GROUPS/CERT_DOMAINS 或检查 $CONF),本次跳过"
exit 0
fi
NGROUPS=$(echo "$GROUPS" | grep -c ':' || true)
say "证书组($NGROUPS): $(echo "$GROUPS" | tr '\n' '|')"
# ── 3. 记录当前生效证书的指纹,用于判断续期后要不要 reload ──────────
cur_fp() { # <证书路径>
[ -s "$1" ] || { echo ""; return; }
openssl x509 -in "$1" -noout -pubkey 2>/dev/null | openssl md5 | awk '{print $NF}'
}
# 说明:HomeDash 自用的那对证书固定落在 $CERT_DIR/{fullchain,privkey}.pem
# (由 process_group 从"第一个组"复制而来),gateway.yml 的 certFile 指向它。
# ── 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"
# ── 推送目标(可空 = 只自用)─────────────────────────────────────────
#
# CERT_PUSH_TARGETS 每行一个目标:
# <host>:<远端cert路径>:<远端key路径>:<reload 命令>
# 例(推到云主机 SafeLine,并重载 tengine + splitter):
# CERT_PUSH_TARGETS="root@1.2.3.4:/data/safeline/resources/nginx/certs/x.crt:/data/safeline/resources/nginx/certs/x.key:docker exec safeline-tengine nginx -s reload && docker exec splitter nginx -s reload"
#
# 认证:优先 CERT_PUSH_SSH_KEY,其次 CERT_PUSH_SSH_PASS,都没有则免密。
PUSH_TARGETS="${CERT_PUSH_TARGETS:-}"
SSH_KEY="${CERT_PUSH_SSH_KEY:-}"
SSH_PASS_ARG="${CERT_PUSH_SSH_PASS:-}"
ssh_do() {
if [ -n "$SSH_PASS_ARG" ]; then
sshpass -p "$SSH_PASS_ARG" ssh $SSH_KEY -o StrictHostKeyChecking=no -o ConnectTimeout=10 "$@"
elif [ -n "$SSH_KEY" ]; then
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o ConnectTimeout=10 "$@"
else
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "$@"
fi
}
# push_cert <组名> <cert> <key>
# ★ 先验配对再推:半截文件推到线上会让 nginx 直接起不来。
push_cert() {
_g="$1"; _c="$2"; _k="$3"
[ -s "$_c" ] && [ -s "$_k" ] || { say "[$_g] 证书不完整,跳过推送"; return 0; }
_pc=$(openssl x509 -in "$_c" -noout -pubkey 2>/dev/null | openssl pkey -pubin -outform DER 2>/dev/null | md5sum)
_pk=$(openssl pkey -in "$_k" -pubout -outform DER 2>/dev/null | md5sum)
if [ -z "${_pc%% *}" ] || [ "${_pc%% *}" != "${_pk%% *}" ]; then
say "[$_g] cert/key 公钥不匹配,拒绝推送(防半截文件上线)"
return 0
fi
[ -n "$PUSH_TARGETS" ] || return 0
# ★ 必须按**行**切分,不能 `for x in $PUSH_TARGETS`(默认按空格分词):
# reload 命令里含空格("nginx -s reload"),按空格切会把一行目标
# 拆成 host/"-s"/"reload" 三片,产生三条无意义的推送。
# 做法:把 IFS 临时设为换行,for 完再恢复。
_old_ifs="$IFS"
IFS='
'
# shellcheck disable=SC2086 # 依赖上面设置的 IFS=换行
for _line in $PUSH_TARGETS; do
[ -n "$_line" ] || continue
_host=$(printf '%s' "$_line" | cut -d: -f1)
_rc=$(printf '%s' "$_line" | cut -d: -f2)
_rk=$(printf '%s' "$_line" | cut -d: -f3)
_rl=$(printf '%s' "$_line" | cut -d: -f4-)
[ -n "$_host" ] && [ -n "$_rc" ] && [ -n "$_rk" ] || continue
if ssh_do "$_host" "mkdir -p \$(dirname '$_rc')" >/dev/null 2>&1 \
&& cat "$_c" | ssh_do "$_host" "cat > '$_rc'" 2>>"$LOG" \
&& cat "$_k" | ssh_do "$_host" "cat > '$_rk' && chmod 600 '$_rk'" 2>>"$LOG"; then
say "[$_g] 已推送 $_host:$_rc"
if [ -n "$_rl" ]; then
ssh_do "$_host" "$_rl" >/dev/null 2>&1 && say "[$_g] $_host reload 已执行" \
|| say "[$_g] $_host reload 未执行(下次重试)"
fi
else
say "[$_g] 推送 $_host 失败(下次重试)"
fi
done
IFS="$_old_ifs"
}
# ── 处理一个证书组 ───────────────────────────────────────────────────
process_group() {
_g="$1"; _doms="$2" # _doms 是逗号分隔
_dd=$(printf '%s' "$_doms" | tr ',' ' ')
_primary=$(printf '%s' "$_doms" | cut -d, -f1)
_cdir="$CERT_DIR/$_g"
_c="$_cdir/fullchain.pem"; _k="$_cdir/privkey.pem"
mkdir -p "$_cdir"
_before=$(cur_fp "$_c")
if "$ACME" --list --home "$HOME_DIR" 2>/dev/null | grep -qF "$_primary"; then
say "[$_g] 已存在证书,交由 acme.sh 判断续期窗口"
"$ACME" --renew -d "$_primary" --ecc --home "$HOME_DIR" >>"$LOG" 2>&1
else
say "[$_g] 首次签发: $_dd"
_flags=""
for _one in $_dd; do _flags="$_flags -d $_one"; done
# shellcheck disable=SC2086 # _flags 需拆成多个参数
"$ACME" --issue --dns "$DNS_PLUGIN" $_flags --ecc --server "$CA" \
--home "$HOME_DIR" --dnssleep 120 >>"$LOG" 2>&1
fi
# install-cert 把 cert/key 落到数据卷,并登记 reloadcmd(续期即生效)
"$ACME" --install-cert -d "$_primary" --ecc --home "$HOME_DIR" \
--fullchain-file "$_c" --key-file "$_k" \
--reloadcmd "$LE_RELOAD_CMD" >>"$LOG" 2>&1 || say "[$_g] install-cert 失败(下次重试)"
# 自用:gateway.yml 若指向 /etc/nginx/certs,这里也放一份到挂载目录可见处
[ -s "$_c" ] && install -m 0644 "$_c" "$CERT_DIR/fullchain.pem" 2>/dev/null
[ -s "$_k" ] && install -m 0600 "$_k" "$CERT_DIR/privkey.pem" 2>/dev/null
_after=$(cur_fp "$_c")
if [ -n "$_after" ] && [ "$_after" != "$_before" ]; then
say "[$_g] 证书已更新($_before → $_after)"
le_reload # 自己的 nginx
else
say "[$_g] 证书未变化"
fi
# 无论变没变都同步远端:远端可能缺文件或被别人覆盖过
push_cert "$_g" "$_c" "$_k"
}
run_all_groups() {
# 同样按行切分(组定义里域名不含空格,但保持一致更安全)。
_old_ifs="$IFS"
IFS='
'
# shellcheck disable=SC2086 # 依赖上面设置的 IFS=换行
for _line in $GROUPS; do
[ -n "$_line" ] || continue
_g=$(printf '%s' "$_line" | cut -d: -f1)
_d=$(printf '%s' "$_line" | cut -d: -f2- | tr -d ' ')
[ -n "$_g" ] && [ -n "$_d" ] || continue
process_group "$_g" "$_d"
done
IFS="$_old_ifs"
}
# ── reload(自用)────────────────────────────────────────────────────
# --install-cert 的 --reloadcmd:续期后 acme.sh 自动执行,实现"续期即生效"。
#
# 直接用 nginx -s reload,不走 admin-server 的 /api/gateway/apply:
# 那个接口需要 admin token(容器内拿不到),而 /api/reload 是个空壳
# (只返回 {"status":"saved"} 并不真的重载,见 admin-server/main.go:1338)。
# 证书路径没变、只是文件内容换了,所以不必重生成 gateway.conf。
#
# ★ 这是命令串,必须用 eval 执行;直接当命令名跑会在 dash 下报
# "LE_RELOAD_CMD: not found",容器里 reload 静默失效。
LE_RELOAD_CMD="nginx -t && nginx -s reload"
le_reload() { eval "$LE_RELOAD_CMD"; }
say "=== 首次执行 ==="
run_all_groups
# ── 7. 循环 ──────────────────────────────────────────────────────────
while :; do
sleep "$INTERVAL"
say "=== 周期检查开始 ==="
run_all_groups
done