#!/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 每行一个目标: # :<远端cert路径>:<远端key路径>: # 例(推到云主机 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 <组名> # ★ 先验配对再推:半截文件推到线上会让 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