#!/usr/bin/env bash # HomeDash host 模式部署(切换成"单容器即入口"之后使用) # # 为什么不能用 deploy/zd.sh: # zd.sh 靠"改门户 nginx 的上游端口 + 双端口轮换"实现零停机,前提是入口 # (portal-nginx)独立于应用容器。切换之后入口就是 navi 容器本身, # host 网络下端口即入口,没有可轮换的第二组端口 —— 那套脚本会失效。 # # 本脚本的策略(用户已明确接受数秒闪断): # 构建 → 上传 → 停旧容器 → 起新容器(host 网络)→ 全面校验 → 改名 # 任一步失败:删掉新容器、把旧容器改名回去并启动(自动回滚) # # 用法: deploy/zd-host.sh # 环境变量: NAVI_HOST / NAVI_SSH_PASS / NAVI_DATA_DIR / NAVI_CERT_DIR / NAVI_BASE_IMAGE set -euo pipefail VER="${1:?用法: deploy/zd-host.sh }" 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 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" < "$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" # 入口自检:探测域名不写死(脱敏时曾把真实域名换成占位符,导致自检去探测 # 一个路由表里不存在的域名而误判失败)。默认取路由表第一条的 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 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" # 收尾断言:名字必须回到 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