Files
HomeDash/deploy/zd-host.sh
HomeDash Bot dbdfe0faef fix(deploy): 默认不改门户口令 + apply 后等待监听就绪
两个问题,都由本轮真实部署暴露:

1) 口令轮换不该默认执行
   集成明文 env 清理时顺手加了"重置为强随机口令",但重置会让所有受保护域名
   (含用户访问 agent 界面的入口)立即要求新口令,用户若没读到日志就等于被锁在门外。
   而生效凭据本是数据卷里的加盐哈希(/app/data/portal-auth.json),删掉明文 env
   对登录毫无影响。→ 改为仅清理 env,轮换必须显式 NAVI_ROTATE_PORTAL_PASSWORD=1。

2) apply 触发的 nginx reload 是异步的
   本次部署因此误判失败:受保护域名探测到 404(旧配置仍在生效),脚本判定
   "应 302 实际 404" 并回滚。→ apply 之后、入口自检之前加入轮询等待就绪(最多 10s),
   与 README「接口注意事项」第 4 条同一原因。
2026-09-12 10:42:44 +08:00

211 lines
11 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"
(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" .)
cp nginx.conf supervisord.conf searxng-settings.yml conf.example.yml "$CTX/"
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
COPY web/extension/install.html /app/dashy/extension/install.html
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
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:-}' 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)' > "/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
# ② 跨子域单点登录所需变量:新版已移除代码里的写死默认值,
# 不显式提供的话登录 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:ro" \
--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"
# apply 触发的 nginx reload 是异步的:不等就绪直接探测会拿到 404,
# 造成"明明升级成功却判失败"(本次真实踩到,与 README 接口注意事项第 4 条同因)。
for _w in $(seq 1 20); do
_c=$(curl -sk -o /dev/null -w "%{http_code}" -m 3 --resolve "$CHECK_HOST:${ENTRY_PORT:-3080}:127.0.0.1" \
"https://$CHECK_HOST:${ENTRY_PORT:-3080}/" 2>/dev/null || echo 000)
[ "$_c" != "000" ] && break
sleep 0.5
done
# 入口自检:受保护域名应 302、公开域名应非 5xx、未知域名应 404(自定义页)
CODE=$(curl -sk -o /dev/null -w '%{http_code}' -m 8 --resolve dashy.example.com:3080:127.0.0.1 https://dashy.example.com:3080/conf.yml)
[ "$CODE" = "302" ] || { echo "!! 受保护域名应 302,实际 $CODE" >&2; false; }
CODE=$(curl -sk -o /dev/null -w '%{http_code}' -m 8 --resolve nobody.test:3080:127.0.0.1 https://nobody.test:3080/)
[ "$CODE" = "404" ] || { echo "!! 未知域名应 404,实际 $CODE" >&2; false; }
CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 8 -H 'Host: dashy.example.com' 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
HIST=$(curl -s "http://127.0.0.1:18080/admin/api/history" | grep -o '"url"' | wc -l)
echo " 历史条数 ${HIST}(同步接口只累加不删除;与切换前对比由人工确认)"
docker rename navi-next navi
trap - ERR
docker rm -f "$PREV" >/dev/null 2>&1 || true
rm -f "$REMOTE_TAR"
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