Files
HomeDash/deploy/zd-host.sh
HomeDash Bot 2eeaabb200 fix(deploy): NAVI_COOKIE_DOMAIN/NAVI_HOME_URL 未转发到远端,升级被自家守卫拦下
现象:本地已传 NAVI_COOKIE_DOMAIN=example.com,部署仍在远端报
"缺少 PORTAL_COOKIE_DOMAIN ... "(守卫在远端执行,而远端 heredoc 是引号形式,
不会展开本地变量)→ 升级被拦,生产保持 ui-v53 未变。

修复:在 ssh 命令行显式转发这两个变量(本地展开后作为远端环境变量传入)。
实测:远端能读到传入值。
2026-09-12 10:39:59 +08:00

197 lines
9.9 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"
# 入口自检:受保护域名应 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 时,换成强随机口令并只打印一次
# (失败不影响升级:可在后台「统一登录账号」自行设置)
if [ "${CLEAN_PORTAL_PW:-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