用户要求「部署时接管全部证书」。原来只签一组(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 已写入)。
309 lines
14 KiB
Bash
Executable File
309 lines
14 KiB
Bash
Executable File
#!/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 |