From 96e771d54628ee047a6df52be97775c9f40726ab Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 08:52:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(cert):=20cert-agent=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E8=AF=81=E4=B9=A6=E7=BB=84=20+=20=E5=A4=9A=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=8E=A8=E9=80=81=EF=BC=88=E5=85=A8=E9=87=8F=E6=8E=A5?= =?UTF-8?q?=E7=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求「部署时接管全部证书」。原来只签一组(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 每行 ':<远端cert>:<远端key>:', 把证书推到其它机器(云主机 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 已写入)。 --- README.md | 38 ++++++-- deploy/cert-agent.sh | 227 +++++++++++++++++++++++++++++++------------ 2 files changed, 197 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 6df673a..36ccca8 100644 --- a/README.md +++ b/README.md @@ -546,7 +546,7 @@ NODE_PATH=/root/node_modules PORTAL_BASE=http://:18080 node deploy/verify- ### 容器内自动续签(cert-agent) -HomeDash 可以自己给**自己**签证书并自动续期,不再依赖外部签发链路。 +HomeDash 自己签证书、自动续期,并可推送到其它机器 —— 不再依赖外部签发链路。 容器内多了一个 supervisord 任务 `cert-agent`(`deploy/cert-agent.sh`),它会: @@ -567,14 +567,40 @@ CERT_DOMAINS=navi.example.com \ | 环境变量 | 作用 | | --- | --- | -| `CERT_DNS_CRED` | DNS API 凭据,如 `Ali_Key=xxx,Ali_Secret=yyy` | -| `CERT_DNS_API` | 可选,默认按凭据键名自动判断(`dns_ali`/`dns_cf`/…) | -| `CERT_DOMAINS` | 可选,默认从 `gateway.yml` 的 `host` 提取 | +| `CERT_DNS_CRED` | DNS API 凭据,如 `Ali_Key=xxx,Ali_Secret=yyy`(**必填**,缺了安静退出) | +| `CERT_DNS_API` | 可选,按凭据键名自动判断(`dns_ali`/`dns_cf`/…) | +| `CERT_GROUPS` | **证书组**,每行 `<组名>:<域名1,域名2,...>`,见下 | +| `CERT_DOMAINS` | 单组简化写法(等价于名为 `default` 的一组) | | `CERT_ACME_CA` | 可选,`letsencrypt`(默认)或 `zerossl` | +| `CERT_PUSH_TARGETS` | 推送目标,每行 `:<远端cert>:<远端key>:` | +| `CERT_PUSH_SSH_KEY` / `CERT_PUSH_SSH_PASS` | 推送用的 SSH 密钥 / 口令,都没有则免密 | +| `CERT_ACME_PROXY` | 可选,运行时安装 acme.sh 时的代理 | | `CERT_RENEW_INTERVAL` | 可选,检查间隔秒数,默认 43200(12h) | -**边界**:它只管 HomeDash 自己这张证书,`introduce.*`/`sdk.homeagent` 等其它站点 -仍走各自外部链路。凭据只从环境变量读,**不落盘、不入库**(见脱敏清单)。 +#### 接管多张证书(按「组」组织) + +同一张证书常覆盖多个域名(SAN),且签发/续期/推送都以**证书**为单位,所以按组配置: + +```bash +CERT_GROUPS='wild:*.jianfgit.xyz,jianfgit.xyz + introduce:introduce.homeagent.jianfgit.xyz + sdk:sdk.homeagent.jianfgit.xyz' +``` + +每组独立落到 `/app/data/certs/<组名>/{fullchain,privkey}.pem`。 +不配置任何组时回落到 `gateway.yml` 的 `host` 列表(只管自己)。 + +#### 推送到其它机器 + +容器是**证书真源**(它能直连 DNS API),别处是消费点: + +```bash +CERT_PUSH_TARGETS='root@<云主机>:/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/key 公钥配对**——半截文件推到线上会让 nginx 直接起不来。 + +凭据只从环境变量读,**不落盘、不入库**(见脱敏清单)。 **改完记得**:`gateway.yml` 的 `certFile`/`keyFile` 要指向 `/app/data/certs/fullchain.pem` 与 `.../privkey.pem`(容器自签时用),再 `POST /admin/api/gateway/apply`。 diff --git a/deploy/cert-agent.sh b/deploy/cert-agent.sh index c63e4c4..b6b8597 100755 --- a/deploy/cert-agent.sh +++ b/deploy/cert-agent.sh @@ -37,8 +37,10 @@ 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,本容器不自动续签(证书由外部链路提供)" +# 只要有 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 @@ -86,21 +88,37 @@ if [ ! -x "$ACME" ]; then [ -x "$ACME" ] || { say "安装后仍找不到 $ACME,跳过本次"; exit 0; } 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' ' ') +# ── 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 -DOMAINS="$(echo "$DOMAINS" | xargs)" -if [ -z "$DOMAINS" ]; then - say "未能确定要签发的域名(设 CERT_DOMAINS 或检查 $CONF),本次跳过" +if [ -z "$GROUPS" ]; then + say "未确定要签发的域名(设 CERT_GROUPS/CERT_DOMAINS 或检查 $CONF),本次跳过" exit 0 fi -say "目标域名: $DOMAINS" +NGROUPS=$(echo "$GROUPS" | grep -c ':' || true) +say "证书组($NGROUPS): $(echo "$GROUPS" | tr '\n' '|')" # ── 3. 记录当前生效证书的指纹,用于判断续期后要不要 reload ────────── cur_fp() { # <证书路径> @@ -108,9 +126,8 @@ cur_fp() { # <证书路径> 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")" +# 说明: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 …) @@ -143,64 +160,150 @@ for kv in $CERT_DNS_CRED; do done IFS="$save_ifs" -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 -} +# ── 推送目标(可空 = 只自用)───────────────────────────────────────── +# +# 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:-}" -# 首次签发 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 +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 - say "首次签发:$DOMAINS" - run_acme + ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "$@" fi } -issue_if_needed -# ── 5. 把证书装到数据卷 ───────────────────────────────────────────── -# --install-cert 让 acme.sh 把 cert/key 落到数据卷,并在**每次续期后**自动 -# 重跑这个 --install-cert(acme.sh 会把它写进 Le_ReloadCmd),从而续期即生效。 +# 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 自动执行,实现"续期即生效"。 # -# reload 直接用 nginx -s reload,不走 admin-server 的 /api/gateway/apply: +# 直接用 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 会静默失效。 +# (只返回 {"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"; } -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 +say "=== 首次执行 ===" +run_all_groups # ── 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 + run_all_groups done \ No newline at end of file