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 等仍走外部链路。
凭据只从环境变量读,不落盘不入库。
This commit is contained in:
JianFeeeee
2026-10-04 08:02:41 +08:00
parent 681393c8ec
commit e7578aa607
6 changed files with 227 additions and 5 deletions

View File

@ -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

View File

@ -544,6 +544,41 @@ NODE_PATH=/root/node_modules PORTAL_BASE=http://<host>: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. 续期后比<E5908E><E6AF94>证书公钥指纹,**变了才 `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-*` 均在)

162
deploy/cert-agent.sh Executable file
View File

@ -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

View File

@ -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

View File

@ -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; }

View File

@ -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