Files
HomeDash/deploy/zd-host.sh
HomeDash 71d0c06b67 fix(deploy): 历史条数探测不应阻断升级
管理 API 改为默认拒绝后 /admin/api/history 需要登录,
原本裸 curl + grep 在 set -e 下会让整个部署判为失败并回滚
(实测 v107 首次部署就撞上这一次)。

改成:带 Bearer token、限时、任何异常都回落 0 并继续。
历史条数本来就只是给人对比用的参考值。
2026-10-22 14:18:00 +08:00

290 lines
15 KiB
Bash
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.

#!/usr/bin/env bash
# HomeDash host 模式部署(切换成"单容器即入口"之后使用)
#
# 为什么不能用 deploy/zd.sh:
# zd.sh 靠"改门户 nginx 的上游端口 + 双端口轮换"实现零停机,前提是入口
# (portal-nginx)独立于应用容器。切换之后入口就是 navi 容器本身,
# host 网络下端口即入口,没有可轮换的第二组端口 —— 那套脚本会失效。
#
# 本脚本的策略(用户已明确接受数秒闪断):
# 构建 → 上传 → 停旧容器 → 起新容器(host 网络)→ 全面校验 → 改名
# 任一步失败:删掉新容器、把旧容器改名回去并启动(自动回滚)
#
# 用法: deploy/zd-host.sh <version>
# 环境变量: NAVI_HOST / NAVI_SSH_PASS / NAVI_DATA_DIR / NAVI_CERT_DIR / NAVI_BASE_IMAGE
set -euo pipefail
VER="${1:?用法: deploy/zd-host.sh <version>}"
TAG="homedash:ui-${VER}"
STAMP="$(date +%Y%m%d%H%M%S)"
HOST="${NAVI_HOST:-}" # 必填:NAVI_HOST=user@host(仓库不预置任何真实地址)
# 必填校验要在赋值之后(set -u 下提前引用会直接 unbound variable)
[ -n "$HOST" ] || { echo "!! 请指定目标机:NAVI_HOST=user@host bash $0 <版本>" >&2; exit 2; }
SSH_PASS="${NAVI_SSH_PASS:-}"
DATA_DIR="${NAVI_DATA_DIR:-/vol1/docker/navi-data}"
CERT_DIR="${NAVI_CERT_DIR:-/vol1/docker/cert}"
BASE_IMAGE="${NAVI_BASE_IMAGE:-jianf-navi:latest}"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CTX=/tmp/homedash-ctx
TAR="/tmp/homedash-${VER}.tar.gz"
REMOTE_TAR="/tmp/homedash-${VER}-${STAMP}.tar.gz"
SSH_OPTS=(-o StrictHostKeyChecking=no)
if [ -n "$SSH_PASS" ]; then
SSH=(sshpass -p "$SSH_PASS" ssh "${SSH_OPTS[@]}" "$HOST")
SCP=(sshpass -p "$SSH_PASS" scp -q "${SSH_OPTS[@]}")
else
SSH=(ssh "${SSH_OPTS[@]}" "$HOST")
SCP=(scp -q "${SSH_OPTS[@]}")
fi
echo "== 1/3 本地构建 ${TAG}(源码 ${SRC})=="
cd "$SRC"
node --check web/navi.js
(cd admin-server && gofmt -l . && go test ./... >/dev/null)
(cd history-server && gofmt -l . && go test ./... >/dev/null)
rm -rf "$CTX"; mkdir -p "$CTX/runtime" "$CTX/web/extension" "$CTX/admin" "$CTX/portal" "$CTX/ext"
(cd history-server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$CTX/runtime/history-server" .)
(cd admin-server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$CTX/runtime/admin-server" .)
# 扩展包必须从当前源码重打:不能继承基镜像里的旧包。
# 实测踩坑(2026-10-02):增量 Dockerfile 只 COPY install.html,zip/xpi 全部
# 来自基镜像,导致 chrome background 的修复升级后仍不生效(包是 3 周前的)。
(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"
cp web/navi.js web/navi.css web/patch-index.py "$CTX/web/"
cat > "$CTX/Dockerfile" <<EOF
FROM ${BASE_IMAGE}
COPY runtime/history-server /usr/local/bin/history-server
COPY runtime/admin-server /usr/local/bin/admin-server
COPY nginx.conf /etc/nginx/nginx.conf
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY searxng-settings.yml /etc/searxng/settings.yml
COPY conf.example.yml /app/defaults/conf.yml
COPY admin/index.html /app/admin/index.html
COPY portal /app/portal
# 站点图标:纸飞机(覆盖 Dashy 自带的那套,双保险)
COPY portal/favicon.svg /app/dashy/favicon.svg
COPY portal/favicon.ico /app/dashy/favicon.ico
COPY portal/apple-touch-icon.png /app/dashy/apple-touch-icon.png
COPY portal/web-icons/ /app/dashy/web-icons/
# /icons/favicon.ico 才是 Dashy 前端实际用的那个:它的 JS 在运行时把
# <link rel=icon> 改写成 /icons/favicon.ico(实测:服务端注入的 /favicon.svg
# 会被它覆盖),而镜像里从来没有 /icons/ 这个目录 —— 所以标签栏一直拿不到图标,
# 请求落到 try_files 兜底的 index.html(content-type: text/html),浏览器直接忽略。
# 静态改 index.html 治不了本,必须把这个路径补上。
COPY portal/favicon.ico /app/dashy/icons/favicon.ico
COPY portal/favicon.svg /app/dashy/icons/favicon.svg
COPY web/extension/install.html /app/dashy/extension/install.html
COPY ext/navi-chrome.zip /app/dashy/extension/navi-chrome.zip
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
EOF
DOCKER_BUILDKIT=0 docker build -q -t "$TAG" "$CTX"
docker save "$TAG" | gzip -1 > "$TAR"
echo " 镜像与包就绪: $(ls -sh "$TAR" | awk '{print $1}')"
echo "== 2/3 上传 =="
"${SCP[@]}" "$TAR" "$HOST:$REMOTE_TAR"
echo "== 3/3 远端切换(接受数秒闪断,失败自动回滚)=="
"${SSH[@]}" "TAG='$TAG' VER='$VER' STAMP='$STAMP' REMOTE_TAR='$REMOTE_TAR' \
DATA_DIR='$DATA_DIR' CERT_DIR='$CERT_DIR' \
NAVI_COOKIE_DOMAIN='${NAVI_COOKIE_DOMAIN:-}' NAVI_HOME_URL='${NAVI_HOME_URL:-}' \
NAVI_ROTATE_ADMIN_TOKEN='${NAVI_ROTATE_ADMIN_TOKEN:-}' \
NAVI_ROTATE_SYNC_TOKEN='${NAVI_ROTATE_SYNC_TOKEN:-}' bash -s" <<'REMOTE'
set -euo pipefail
# 前置检查:必须已经是 host 模式的单容器入口,否则用错脚本会出事
NET=$(docker inspect navi --format '{{.HostConfig.NetworkMode}}' 2>/dev/null || echo none)
if [ "$NET" != "host" ]; then
echo "!! navi 目前是 '$NET' 网络:本脚本只用于切换后的 host 模式单容器" >&2
echo " 切换前请用 deploy/zd.sh;或先执行正式切换。" >&2
exit 1
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|CERT_)' > "/tmp/navi-host-${STAMP}.env" || true
chmod 600 "/tmp/navi-host-${STAMP}.env"
# ① 不再把明文门户口令带进新容器(docker inspect 就能看到它)。
# 实际生效的是数据卷里的加盐哈希,所以丢掉 env 不影响登录;
# 稍后会在新容器里写入一个强度更高的随机口令并打印一次。
CLEAN_PORTAL_PW=0
if grep -q '^PORTAL_PASSWORD=' "/tmp/navi-host-${STAMP}.env"; then
grep -v '^PORTAL_PASSWORD=' "/tmp/navi-host-${STAMP}.env" > "/tmp/navi-host-${STAMP}.env.new" && mv "/tmp/navi-host-${STAMP}.env.new" "/tmp/navi-host-${STAMP}.env"
CLEAN_PORTAL_PW=1
fi
# ③ 令牌轮换:渗透报告证明旧 NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 已泄露
# (/api/sync-info 在公网明文回显过 syncToken),必须支持在部署时换新值。
# 以前本脚本只从旧容器继承 env,导致轮换无处落地。
# 用法:NAVI_ROTATE_ADMIN_TOKEN=<新值> NAVI_ROTATE_SYNC_TOKEN=<新值> bash deploy/zd-host.sh <ver>
# 只打印长度,不打印值。
if [ -n "${NAVI_ROTATE_ADMIN_TOKEN:-}" ]; then
if [ "${#NAVI_ROTATE_ADMIN_TOKEN}" -lt 32 ]; then
echo "!! NAVI_ROTATE_ADMIN_TOKEN 太短(${#NAVI_ROTATE_ADMIN_TOKEN} 字符),至少 32" >&2
exit 2
fi
grep -v '^NAVI_ADMIN_TOKEN=' "/tmp/navi-host-${STAMP}.env" > "/tmp/navi-host-${STAMP}.env.t" || true
echo "NAVI_ADMIN_TOKEN=${NAVI_ROTATE_ADMIN_TOKEN}" >> "/tmp/navi-host-${STAMP}.env.t"
mv "/tmp/navi-host-${STAMP}.env.t" "/tmp/navi-host-${STAMP}.env"
echo " 已轮换 NAVI_ADMIN_TOKEN(新值长度 ${#NAVI_ROTATE_ADMIN_TOKEN})"
fi
if [ -n "${NAVI_ROTATE_SYNC_TOKEN:-}" ]; then
if [ "${#NAVI_ROTATE_SYNC_TOKEN}" -lt 32 ]; then
echo "!! NAVI_ROTATE_SYNC_TOKEN 太短(${#NAVI_ROTATE_SYNC_TOKEN} 字符),至少 32" >&2
exit 2
fi
grep -v '^NAVI_SYNC_TOKEN=' "/tmp/navi-host-${STAMP}.env" > "/tmp/navi-host-${STAMP}.env.t" || true
echo "NAVI_SYNC_TOKEN=${NAVI_ROTATE_SYNC_TOKEN}" >> "/tmp/navi-host-${STAMP}.env.t"
mv "/tmp/navi-host-${STAMP}.env.t" "/tmp/navi-host-${STAMP}.env"
echo " 已轮换 NAVI_SYNC_TOKEN(新值长度 ${#NAVI_ROTATE_SYNC_TOKEN})"
fi
chmod 600 "/tmp/navi-host-${STAMP}.env"
# ② 跨子域单点登录所需变量:新版已移除代码里的写死默认值,
# 不显式提供的话登录 cookie 会退化成 host-only(登录后换子域就掉线)。
if ! grep -q '^PORTAL_COOKIE_DOMAIN=' "/tmp/navi-host-${STAMP}.env"; then
if [ -n "${NAVI_COOKIE_DOMAIN:-}" ]; then
echo "PORTAL_COOKIE_DOMAIN=${NAVI_COOKIE_DOMAIN}" >> "/tmp/navi-host-${STAMP}.env"
else
echo "!! 缺少 PORTAL_COOKIE_DOMAIN:新版已删除代码默认值,直接升级会让跨子域单点登录失效。" >&2
echo " 执行方式:NAVI_COOKIE_DOMAIN=.你的域名 NAVI_HOME_URL=https://你的门户/ NAVI_SSH_PASS=... bash deploy/zd-host.sh $VER" >&2
exit 2
fi
fi
if ! grep -q '^PORTAL_HOME_URL=' "/tmp/navi-host-${STAMP}.env" && [ -n "${NAVI_HOME_URL:-}" ]; then
echo "PORTAL_HOME_URL=${NAVI_HOME_URL}" >> "/tmp/navi-host-${STAMP}.env"
fi
OLD_IMAGE=$(docker inspect navi --format '{{.Config.Image}}')
PREV="navi-prev-${STAMP}"
fail() {
rc=$?
echo "!! 失败(rc=$rc)→ 回滚到 ${OLD_IMAGE}" >&2
docker rm -f navi-next >/dev/null 2>&1 || true
if ! docker inspect "$PREV" >/dev/null 2>&1; then
# 还没改名就把原容器起回来
docker start navi >/dev/null 2>&1 || true
else
docker rename "$PREV" navi >/dev/null 2>&1 || true
docker start navi >/dev/null 2>&1 || true
fi
sleep 2
echo " 回滚后状态: $(docker inspect navi --format '{{.State.Status}} {{.Config.Image}}' 2>/dev/null)" >&2
exit $rc
}
trap fail ERR
gzip -dc "$REMOTE_TAR" | docker load >/dev/null
echo " 停旧容器(闪断开始)"
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" \
--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; }
w200 "http://127.0.0.1:18080/healthz"
w200 "http://127.0.0.1:18080/conf.yml"
w200 "http://127.0.0.1:18080/admin"
# 入口自检:探测域名不写死(脱敏时曾把真实域名换成占位符,导致自检去探测
# 一个路由表里不存在的域名而误判失败)。默认取路由表第一条的 host,
# 也可用 NAVI_CHECK_HOST 指定。apply 触发的 reload 是异步的,先等就绪。
CHECK_HOST="${NAVI_CHECK_HOST:-}"
if [ -z "$CHECK_HOST" ]; then
# 只用无引号的字符类提取域名,避免 tr/sed 的引号嵌套把远端脚本打断
CHECK_HOST=$(grep -m1 'host:' "$DATA_DIR/gateway.yml" 2>/dev/null | grep -oE '[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}' | head -1 || true)
fi
[ -n "$CHECK_HOST" ] || { echo "!! 无法确定自检域名:请传 NAVI_CHECK_HOST=<某个已配置的域名>" >&2; false; }
echo " 自检探测域名: $CHECK_HOST"
for _w in $(seq 1 20); do
_c=$(curl -sk -o /dev/null -w '%{http_code}' -m 3 --resolve "$CHECK_HOST:3080:127.0.0.1" \
"https://$CHECK_HOST:3080/" 2>/dev/null || echo 000)
[ "$_c" != "000" ] && break
sleep 0.5
done
# 已配置的域名必须可服务:门户可能是 200,受保护的是 302 —— 两者都算正常;
# 000(没监听)/404(不在路由表)/5xx(上游或配置坏了)都算失败。
CODE=$(curl -sk -o /dev/null -w '%{http_code}' -m 8 --resolve "$CHECK_HOST:3080:127.0.0.1" "https://$CHECK_HOST:3080/")
case "$CODE" in
200|302) echo " 已配置域名 $CHECK_HOST → $CODE" ;;
*) echo "!! 已配置域名 $CHECK_HOST 应 200/302,实际 $CODE" >&2; false ;;
esac
CODE=$(curl -sk -o /dev/null -w '%{http_code}' -m 8 --resolve nobody.invalid:3080:127.0.0.1 https://nobody.invalid:3080/)
[ "$CODE" = "404" ] || { echo "!! 未知域名应 404,实际 $CODE" >&2; false; }
CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 8 -H "Host: $CHECK_HOST" http://127.0.0.1:3081/)
[ "$CODE" = "302" ] || { echo "!! HTTP 入口应 302 跳转,实际 $CODE" >&2; false; }
echo " 入口自检通过(3080/3081)"
# 让容器按当前路由表重新生成一次网关配置:镜像里的门户模板/错误页可能已更新,
# 而线上 gateway.conf 是上一次生成的(本轮真实事故:门户页占位符裸露)。
ADMIN_TOK=$(grep '^NAVI_ADMIN_TOKEN=' "/tmp/navi-host-${STAMP}.env" | cut -d= -f2- || true)
if [ -n "$ADMIN_TOK" ]; then
APPLY=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOK" http://127.0.0.1:3005/api/gateway/apply || true)
echo " 已重新生成网关配置: ${APPLY}"
case "$APPLY" in *applied*) ;; *) echo "!! 网关配置重新生成失败" >&2; false ;; esac
# ③ 旧容器用过明文 PORTAL_PASSWORD 时,换成强随机口令并只打印一次
# (失败不影响升级:可在后台「统一登录账号」自行设置)
# 只清理明文 env(生效凭据来自数据卷 portal-auth.json,删掉 env 不影响登录)。
# 重置口令会造成"所有受保护域名都要用新口令登录",属高风险操作,
# 因此必须显式开启 NAVI_ROTATE_PORTAL_PASSWORD=1 才执行。
if [ "${CLEAN_PORTAL_PW:-0}" = "1" ] && [ "${NAVI_ROTATE_PORTAL_PASSWORD:-0}" != "1" ]; then
echo " 已丢弃旧容器 env 里的明文 PORTAL_PASSWORD;门户口令保持不变(如确需更换,后台可改,或传 NAVI_ROTATE_PORTAL_PASSWORD=1)"
fi
if [ "${CLEAN_PORTAL_PW:-0}" = "1" ] && [ "${NAVI_ROTATE_PORTAL_PASSWORD:-0}" = "1" ]; then
NEWPW="$( (openssl rand -base64 24 2>/dev/null || head -c 24 /dev/urandom | base64) | tr -d '/+=' | cut -c1-20)"
RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOK" -H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$NEWPW\"}" http://127.0.0.1:3005/api/portal-auth || true)
case "$RESP" in *error*|*Error*) echo " ! 门户口令重置失败(可在后台改):$RESP" >&2 ;;
*) echo " 已清除明文 env 并重置门户口令 → admin / $NEWPW(请立即记录)" ;; esac
fi
fi
# 历史条数只用于人工对比,不应成为升级的阻断点。
# 2026-10-22:管理 API 改为默认拒绝后,/admin/api/* 需要登录会话;
# 这里用管理员 token 访问(v107+ 支持 Bearer),拿不到就不显示而不报错。
HIST=$(curl -s -m 8 -H "Authorization: Bearer ${ADMIN_TOK:-}" \
"http://127.0.0.1:18080/admin/api/history" 2>/dev/null \
| grep -o '"url"' | wc -l || echo 0)
HIST=${HIST:-0}
echo " 历史条数 ${HIST:-0}(同步接口只累加不删除;与切换前对比由人工确认)"
docker rename navi-next navi
trap - ERR
docker rm -f "$PREV" >/dev/null 2>&1 || true
rm -f "$REMOTE_TAR"
# 收尾断言:名字必须回到 navi。中途异常中断曾导致容器留在 navi-next,
# 而脚本"看起来"跑完了(本轮真实事故),所以这里显式检查。
if ! docker inspect navi >/dev/null 2>&1; then
if docker inspect navi-next >/dev/null 2>&1; then
echo "!! 容器仍名为 navi-next(切换未收尾):已核验可直接 docker rename navi-next navi" >&2
fi
false
fi
echo "DEPLOY_OK tag=homedash:ui-${VER} mode=host health=$(docker inspect navi --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}')"
echo "闪断结束(旧容器已删除;镜像 ${OLD_IMAGE} 仍可用于手动回滚)"
REMOTE