From e7578aa607387af5280c74b6cc6202149569d7f7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 08:02:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(cert):=20cert-agent=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E5=AE=B9=E5=99=A8=E5=86=85=E8=87=AA=E7=AD=BE=E8=87=AA?= =?UTF-8?q?=E7=BB=AD=20HomeDash=20=E8=87=AA=E5=B7=B1=E7=9A=84=E8=AF=81?= =?UTF-8?q?=E4=B9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户决策: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 等仍走外部链路。 凭据只从环境变量读,不落盘不入库。 --- Dockerfile | 6 +- README.md | 35 +++++++++ deploy/cert-agent.sh | 162 +++++++++++++++++++++++++++++++++++++++++ deploy/install-host.sh | 7 +- deploy/zd-host.sh | 7 +- supervisord.conf | 15 ++++ 6 files changed, 227 insertions(+), 5 deletions(-) create mode 100755 deploy/cert-agent.sh diff --git a/Dockerfile b/Dockerfile index 7664269..a320a12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -100,6 +100,10 @@ COPY web/extension/install.html /app/dashy/extension/install.html COPY --from=extpack /out/navi-chrome.zip /app/dashy/extension/navi-chrome.zip COPY --from=extpack /out/navi-firefox.xpi /app/dashy/extension/navi-firefox.xpi +# 容器内自动续签(cert-agent)。脚本本身不含任何凭据,凭据只从环境变量读。 +COPY deploy/cert-agent.sh /usr/local/bin/cert-agent +RUN chmod +x /usr/local/bin/cert-agent + # Inject search script into Dashy COPY web/navi.js /app/dashy/navi.js COPY web/navi.css /app/dashy/navi.css @@ -108,7 +112,7 @@ COPY web/patch-index.py /tmp/patch-index.py RUN python3 /tmp/patch-index.py /app/dashy/index.html # Data directory -RUN mkdir -p /app/data/item-icons /app/data/gateway /var/log/supervisor /var/cache/searxng /run/nginx +RUN mkdir -p /app/data/item-icons /app/data/gateway /app/data/certs /app/data/acme /var/log/supervisor /var/cache/searxng /run/nginx VOLUME ["/app/data"] EXPOSE 18080 3080 3081 8443 diff --git a/README.md b/README.md index 9db2d95..6df673a 100644 --- a/README.md +++ b/README.md @@ -544,6 +544,41 @@ NODE_PATH=/root/node_modules PORTAL_BASE=http://:18080 node deploy/verify- 覆盖:触摸左滑切换、面板内容跟随、反向滑回、**竖滑不误触发**、无 `pageerror`。 +### 容器内自动续签(cert-agent) + +HomeDash 可以自己给**自己**签证书并自动续期,不再依赖外部签发链路。 + +容器内多了一个 supervisord 任务 `cert-agent`(`deploy/cert-agent.sh`),它会: + +1. 从 `/app/data/gateway.yml` 的路由表读出要签的域名(或用 `CERT_DOMAINS` 显式指定) +2. 首次运行把 `acme.sh` 装进**数据卷** `/app/data/acme`(容器重建不丢续期状态) +3. 签发并 `install-cert` 到 `/app/data/certs/` +4. 续期后比��证书公钥指纹,**变了才 `nginx -t && nginx -s reload`** + +**启用方式**(不给凭据时它安静退出,行为与现在完全一致): + +```bash +# 升级时传入即可(zd-host.sh 的 env 白名单已包含 CERT_ 前缀) +NAVI_HOST=user@host NAVI_SSH_PASS='…' \ +CERT_DNS_CRED='Ali_Key=…,Ali_Secret=…' \ +CERT_DOMAINS=navi.example.com \ + bash deploy/zd-host.sh <版本> +``` + +| 环境变量 | 作用 | +| --- | --- | +| `CERT_DNS_CRED` | DNS API 凭据,如 `Ali_Key=xxx,Ali_Secret=yyy` | +| `CERT_DNS_API` | 可选,默认按凭据键名自动判断(`dns_ali`/`dns_cf`/…) | +| `CERT_DOMAINS` | 可选,默认从 `gateway.yml` 的 `host` 提取 | +| `CERT_ACME_CA` | 可选,`letsencrypt`(默认)或 `zerossl` | +| `CERT_RENEW_INTERVAL` | 可选,检查间隔秒数,默认 43200(12h) | + +**边界**:它只管 HomeDash 自己这张证书,`introduce.*`/`sdk.homeagent` 等其它站点 +仍走各自外部链路。凭据只从环境变量读,**不落盘、不入库**(见脱敏清单)。 + +**改完记得**:`gateway.yml` 的 `certFile`/`keyFile` 要指向 `/app/data/certs/fullchain.pem` +与 `.../privkey.pem`(容器自签时用),再 `POST /admin/api/gateway/apply`。 + ### 回滚 - 容器:`docker start portal-nginx portal-auth` + 把 `navi` 换回 bridge 版本(镜像 `homedash:ui-*` 均在) diff --git a/deploy/cert-agent.sh b/deploy/cert-agent.sh new file mode 100755 index 0000000..8fadc74 --- /dev/null +++ b/deploy/cert-agent.sh @@ -0,0 +1,162 @@ +#!/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 \ No newline at end of file diff --git a/deploy/install-host.sh b/deploy/install-host.sh index 7b99482..82cd570 100755 --- a/deploy/install-host.sh +++ b/deploy/install-host.sh @@ -13,7 +13,9 @@ # 常用环境变量(全部可覆盖): # NAVI_SRC 仓库目录 默认:脚本上级目录 # NAVI_DATA_DIR 数据卷目录 默认:/vol1/docker/navi-data -# NAVI_CERT_DIR 证书目录(只读挂载)默认:/vol1/docker/cert +# NAVI_CERT_DIR 证书目录 默认:/vol1/docker/cert +# CERT_DNS_CRED 可选。给了就让容器内的 cert-agent 自签自续(见 README +# 「容器内自动续签」);不给则证书由外部链路提供。 # NAVI_TAG 构建出的镜像名 默认:homedash:ui-install # NAVI_ENTRY_PORT HTTPS/HTTP 入口端口 默认:3080 # NAVI_TLS_PORT 额外 TLS 端口 默认:8443 @@ -143,11 +145,12 @@ if [ -z "$BASE_URL" ]; then if [ -n "$DOMAIN" ]; then BASE_URL="https://$DOMAIN/"; else BASE_URL="http://127.0.0.1:${ENTRY_PORT}/"; fi fi docker run -d --name navi --network host --restart unless-stopped \ - -v "${DATA_DIR}:/app/data" -v "${CERT_DIR}:/etc/nginx/certs:ro" \ + -v "${DATA_DIR}:/app/data" -v "${CERT_DIR}:/etc/nginx/certs" \ -e NAVI_SYNC_TOKEN="$SYNC_TOKEN" -e NAVI_ADMIN_TOKEN="$ADMIN_TOKEN" \ -e SEARXNG_BASE_URL="$BASE_URL" \ $([ -n "$DOMAIN" ] && echo "-e PORTAL_COOKIE_DOMAIN=.$DOMAIN") \ $([ -n "$DOMAIN" ] && echo "-e PORTAL_HOME_URL=https://$DOMAIN/") \ + $([ -n "$DOMAIN" ] && [ -n "${CERT_DNS_CRED:-}" ] && echo "-e CERT_DNS_API=dns_ali -e CERT_DNS_CRED=$CERT_DNS_CRED -e CERT_DOMAINS=$DOMAIN") \ "$TAG" >/dev/null echo " 容器已启动,等待健康检查…" for i in $(seq 1 60); do diff --git a/deploy/zd-host.sh b/deploy/zd-host.sh index 39d7add..7b796ec 100644 --- a/deploy/zd-host.sh +++ b/deploy/zd-host.sh @@ -55,6 +55,7 @@ rm -rf "$CTX"; mkdir -p "$CTX/runtime" "$CTX/web/extension" "$CTX/admin" "$CTX/p (cd extension/chrome && zip -qr "$CTX/ext/navi-chrome.zip" .) (cd extension/firefox && zip -qr "$CTX/ext/navi-firefox.xpi" .) cp nginx.conf supervisord.conf searxng-settings.yml conf.example.yml "$CTX/" +cp deploy/cert-agent.sh "$CTX/cert-agent.sh" cp admin/index.html "$CTX/admin/index.html" cp -a portal/. "$CTX/portal/" cp web/extension/install.html "$CTX/web/extension/install.html" @@ -87,6 +88,8 @@ COPY ext/navi-firefox.xpi /app/dashy/extension/navi-firefox.xpi COPY web/navi.js /app/dashy/navi.js COPY web/navi.css /app/dashy/navi.css COPY web/patch-index.py /tmp/patch-index.py +COPY cert-agent.sh /usr/local/bin/cert-agent +RUN chmod +x /usr/local/bin/cert-agent RUN python3 /tmp/patch-index.py /app/dashy/index.html HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \\ CMD curl -fs http://localhost:18080/healthz || exit 1 @@ -114,7 +117,7 @@ fi OLD_ID=$(docker inspect navi --format '{{.Id}}') docker inspect navi --format '{{range .Config.Env}}{{println .}}{{end}}' \ - | grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|PORTAL_|DEBIAN_FRONTEND)' > "/tmp/navi-host-${STAMP}.env" || true + | grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|PORTAL_|DEBIAN_FRONTEND|CERT_)' > "/tmp/navi-host-${STAMP}.env" || true chmod 600 "/tmp/navi-host-${STAMP}.env" # ① 不再把明文门户口令带进新容器(docker inspect 就能看到它)。 @@ -167,7 +170,7 @@ docker stop navi >/dev/null docker rename navi "$PREV" docker run -d --name navi-next --restart unless-stopped --network host \ - -v "${DATA_DIR}:/app/data" -v "${CERT_DIR}:/etc/nginx/certs:ro" \ + -v "${DATA_DIR}:/app/data" -v "${CERT_DIR}:/etc/nginx/certs" \ --env-file "/tmp/navi-host-${STAMP}.env" "homedash:ui-${VER}" >/dev/null w200() { for _ in $(seq 1 60); do [ "$(curl -s -o /dev/null -w '%{http_code}' -m 3 "$1" 2>/dev/null || true)" = "200" ] && { echo " ready $1"; return 0; }; sleep 2; done; echo " TIMEOUT $1" >&2; return 1; } diff --git a/supervisord.conf b/supervisord.conf index 6e628b9..b22022e 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -47,3 +47,18 @@ stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 + +[program:cert-agent] +# 容器内自动续签 HomeDash **自己**的证书(不接管其它站点)。 +# 未配置 CERT_DNS_API/CERT_DNS_CRED 时脚本会安静退出,不影响入口, +# 证书仍由外部链路(acme.sh → 同步)提供。 +command=/usr/local/bin/cert-agent +autostart=true +# autorestart=false:脚本自己内含 sleep 循环,异常退出会停止续签, +# 但重启它也没用(凭据问题会再退),所以不自动重启以免刷日志。 +autorestart=false +startsecs=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0