用户决策:scope=self-only,strategy=full-acme(容器内跑完整 acme.sh)。 ## 动机 此前 HomeDash 的证书由外部链路提供(.106 acme.sh → .60 → 云主机), 而容器是**只读**挂载 /etc/nginx/certs(RW=false),续期后没人告诉它的 nginx 重新握手。本 agent 让 HomeDash 自己闭环:签发 → 写数据卷 → reload。 ## 实现 - deploy/cert-agent.sh:supervisord 新增 [program:cert-agent] - 域名从 /app/data/gateway.yml 的 host 提取(或 CERT_DOMAINS 显式指定) - acme.sh 装进**数据卷** /app/data/acme(容器重建不丢续期状态) - install-cert 到 /app/data/certs/,--reloadcmd 设为 nginx -t && nginx -s reload - 续期后比对公钥指纹,变了才 reload(不盲目 reload) - 未配置 CERT_DNS_CRED 时安静退出,行为与现在完全一致 - 证书目录挂载去掉 :ro(自签需要写;此前只读是刻意的保护) - zd-host.sh 的 env 白名单加 CERT_ 前缀,否则升级时凭据传不进新容器 - install-host.sh 同步支持 CERT_DNS_CRED,避免两套脚本行为不一致 - 证书路径改为数据卷 /app/data/certs/(gateway.yml 指向它) ## 测试(沙箱 stub,非真实签发) - 无凭据 → 安静退出、exit 0、不调 acme、不报错 - 有凭据 → 正确从 gateway.yml 提取 2 个域名、识别 dns_ali、 签发参数正确、指纹变化后触发 reload ## 途中修掉自己的两个真 bug ① run_acme 里用 `set -- $DOMAINS` 改函数位置参数,dash 下与后续变量 展开冲突 ② LE_RELOAD 是命令串,直接当命令名执行 → "LE_RELOAD: not found", 容器里 reload 会**静默失效**;改为函数 + eval ## 边界 只管 HomeDash 自己;introduce/sdk.homeagent 等仍走外部链路。 凭据只从环境变量读,不落盘不入库。
162 lines
7.8 KiB
Bash
Executable File
162 lines
7.8 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. 未配置凭据就安静退出:没有它本脚本无事可做,也不该刷日志 ──
|
||
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(数据卷里已存在就跳过)──
|
||
ACME="$HOME_DIR/acme.sh"
|
||
if [ ! -x "$ACME" ]; then
|
||
say "首次运行:安装 acme.sh 到 $HOME_DIR"
|
||
curl -fsSL https://get.acme.sh -o "$HOME_DIR/acme.sh.install" 2>>"$LOG" || {
|
||
say "下载 acme.sh 失败,跳过本次"
|
||
exit 0
|
||
}
|
||
sh "$HOME_DIR/acme.sh.install" --home "$HOME_DIR" --nocron >>"$LOG" 2>&1 || {
|
||
say "安装 acme.sh 失败,跳过本次"
|
||
exit 0
|
||
}
|
||
rm -f "$HOME_DIR/acme.sh.install"
|
||
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"
|
||
|
||
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 |