Files
HomeDash/deploy/cert-agent.sh
JianFeeeee e7578aa607 feat(cert): cert-agent —— 容器内自签自续 HomeDash 自己的证书
用户决策: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 等仍走外部链路。
凭据只从环境变量读,不落盘不入库。
2026-10-04 08:02:41 +08:00

162 lines
7.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(数据卷里已存在就跳过)──
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