Files
HomeDash/deploy/zd-host.sh
JianFeeeee aae3fb7065 fix(deploy): 历史条数不再假报 0,明文令牌的 /tmp env 文件必删
两处都是「看起来成功、实际给了错误信息/留下凭据」的问题。

A. 历史条数假数据(行为变化)
   旧逻辑:ADMIN_TOK=$(grep ... || true) 取不到时仍发一个空的 `Bearer `
   请求;第 1 步把 history-server 改成 fail-closed 后它必然回 401 JSON,
   而 `grep -o '"url"' | wc -l` 对错误响应数出 0,再被 HIST=${HIST:-0}
   兜一层,于是对外打印「历史条数 0」—— 一个假数据,看起来像历史被清空。
   现在抽成 human_history_count(),三条路径分开:
     · 未配置 NAVI_ADMIN_TOKEN → 「未知(未配置…跳过探测)」,不发任何请求
     · 非 200(401/502/连不上的 000) → 「未知(HTTP xxx)」,不数 body
     · 只有 200 才数 '"url"' 出现次数
   ⚠️ 行为变化:token 取不到时历史条数显示「未知」而不是「0」;
   真实的 0 条(200 + 空列表)仍然显示 0。仍旧绝不阻断升级。

B. 明文令牌的 env 文件残留
   /tmp/navi-host-<stamp>.env 含 NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 明文,
   chmod 600 只防别的用户读,不防它一直躺在 /tmp。现在:
     · 统一为 $ENV_FILE(含 .t / .new 中间文件),不再散落字面量路径
     · 新增 cleanup_env_file(),同时删三个文件
     · 注册 trap ... EXIT(任何 exit 都删)+ fail() 回滚路径显式调用
       + 成功收尾显式调用(三处)
   删它不影响已起容器:docker run 已在启动时把值注入容器 env。

顺带确认(未改动,仅补注释):重置门户口令后打印一次 $NEWPW 是有意设计
——那是管理员唯一一次拿到它的机会(数据卷只存哈希),注释已写明
「只打印这一次」,且不写日志文件、不进任何 env。

新增 deploy/zd-host_test.sh(bash deploy/zd-host_test.sh,退出码 0=全绿):
1. bash -n 语法检查;
2. 静态护栏:轮换只打印 ${#VAR} 长度、无 set -x、无 HIST=${HIST:-0}、
   无「grep url | wc -l || echo 0」旧写法、必须校验 HTTP 状态码、
   env 文件在 fail() 与 EXIT trap 与成功路径上都有删除、中间文件也删;
3. 用 BEGIN/END 标记把 human_history_count 原样抠出来,配 stub curl 单测
   空 token / 401 / 502 / 连接失败 / 200 三条 / 200 空列表 六种情形,
   并断言空 token 时根本没发请求、有 token 时带了 Authorization 头。

反向验证:把历史条数换回旧写法并删掉三处 env 文件清理后,
测试从 26 通过 / 0 失败变成 10 通过 / 16 失败(列出「仍有 HIST=${HIST:-0}」
「fail() 回滚路径没有删 env 文件」「空 token 时仍然调用了 curl」等)。
另外用第 1 步的真实 history-server + 模拟两层鉴权的反代做了端到端复核,
旧写法在 401/连接失败下都报 0,新写法分别报「未知(HTTP 401)」「未知(HTTP 000)」,
200 + 3 条报 3,200 + 空列表报 0。
2026-10-22 23:25:26 +08:00

335 lines
17 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}}')
# 该文件含**明文令牌**(NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 等)。chmod 600 只防别的
# 用户读,不能防它一直躺在 /tmp 里,所以成功、失败回滚、以及任何 exit 都要删。
# 删它不影响已起的容器:docker run 已在启动时把值注入容器 env(--env-file 只读一次)。
ENV_FILE="/tmp/navi-host-${STAMP}.env"
cleanup_env_file() {
rm -f "$ENV_FILE" "$ENV_FILE.t" "$ENV_FILE.new" 2>/dev/null || true
}
trap cleanup_env_file EXIT
docker inspect navi --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|PORTAL_|DEBIAN_FRONTEND|CERT_)' > "$ENV_FILE" || true
chmod 600 "$ENV_FILE"
# ① 不再把明文门户口令带进新容器(docker inspect 就能看到它)。
# 实际生效的是数据卷里的加盐哈希,所以丢掉 env 不影响登录;
# 稍后会在新容器里写入一个强度更高的随机口令并打印一次。
CLEAN_PORTAL_PW=0
if grep -q '^PORTAL_PASSWORD=' "$ENV_FILE"; then
grep -v '^PORTAL_PASSWORD=' "$ENV_FILE" > "$ENV_FILE.new" && mv "$ENV_FILE.new" "$ENV_FILE"
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=' "$ENV_FILE" > "$ENV_FILE.t" || true
echo "NAVI_ADMIN_TOKEN=${NAVI_ROTATE_ADMIN_TOKEN}" >> "$ENV_FILE.t"
mv "$ENV_FILE.t" "$ENV_FILE"
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=' "$ENV_FILE" > "$ENV_FILE.t" || true
echo "NAVI_SYNC_TOKEN=${NAVI_ROTATE_SYNC_TOKEN}" >> "$ENV_FILE.t"
mv "$ENV_FILE.t" "$ENV_FILE"
echo " 已轮换 NAVI_SYNC_TOKEN(新值长度 ${#NAVI_ROTATE_SYNC_TOKEN})"
fi
chmod 600 "$ENV_FILE"
# ② 跨子域单点登录所需变量:新版已移除代码里的写死默认值,
# 不显式提供的话登录 cookie 会退化成 host-only(登录后换子域就掉线)。
if ! grep -q '^PORTAL_COOKIE_DOMAIN=' "$ENV_FILE"; then
if [ -n "${NAVI_COOKIE_DOMAIN:-}" ]; then
echo "PORTAL_COOKIE_DOMAIN=${NAVI_COOKIE_DOMAIN}" >> "$ENV_FILE"
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=' "$ENV_FILE" && [ -n "${NAVI_HOME_URL:-}" ]; then
echo "PORTAL_HOME_URL=${NAVI_HOME_URL}" >> "$ENV_FILE"
fi
OLD_IMAGE=$(docker inspect navi --format '{{.Config.Image}}')
PREV="navi-prev-${STAMP}"
fail() {
rc=$?
echo "!! 失败(rc=$rc)→ 回滚到 ${OLD_IMAGE}" >&2
cleanup_env_file
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 "$ENV_FILE" "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=' "$ENV_FILE" | 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 ;;
# 这里**有意**把新口令打印一次:这是管理员唯一能拿到它的机会(数据卷里只存哈希)。
# 打印到此为止 —— 不写日志文件、不写进任何 env/注释,也不在任何其它路径回显。
*) echo " 已清除明文 env 并重置门户口令 → admin / $NEWPW(请立即记录,只打印这一次)" ;; esac
fi
fi
# 历史条数只用于人工对比,不应成为升级的阻断点。
# 2026-10-22:管理 API 改为默认拒绝后,/admin/api/* 需要登录会话;
# 这里用管理员 token 访问(v107+ 支持 Bearer),拿不到就不显示而不报错。
#
# 2026-10-22 审计补修:以前取不到 token 时仍会发一个空的 `Bearer ` 请求,
# history-server fail-closed 回 401 JSON,`grep -o '"url"' | wc -l` 数出 0,
# 于是对外**打印「历史条数 0」——一个假数据**(看起来像历史被清空了)。
# 现在三条路径分开:无 token → 「未知(跳过)」;非 200 → 「未知(HTTP xxx)」;
# 只有 200 才数 body。仍然绝不阻断升级。
# >>> human_history_count BEGIN(deploy/zd-host_test.sh 会抽取这一段做单测,改动请保留标记)
human_history_count() {
# $1 = 管理员 token(可空)
local tok="$1"
if [ -z "$tok" ]; then
echo "未知(未配置 NAVI_ADMIN_TOKEN,跳过探测)"
return 0
fi
local tmp code body n
tmp=$(mktemp 2>/dev/null || echo "/tmp/navi-hist-${STAMP}")
# curl 连不上时仍会把 -w 的 000 写出来并返回非 0,所以先 `|| true` 再归一化,
# 不能 `|| echo 000`(那会拼成 "000\n000" 打进提示里)。
code=$(curl -s -m 8 -o "$tmp" -w '%{http_code}' \
-H "Authorization: Bearer ${tok}" \
"http://127.0.0.1:18080/admin/api/history" 2>/dev/null || true)
code=$(printf '%s' "$code" | tr -d '[:space:]')
[ -n "$code" ] || code=000
if [ "$code" != "200" ]; then
rm -f "$tmp"
echo "未知(HTTP ${code};接口要求管理员 token,失败不影响升级)"
return 0
fi
body=$(cat "$tmp" 2>/dev/null || true)
rm -f "$tmp"
n=$(printf '%s' "$body" | grep -o '"url"' | wc -l || true)
echo "${n:-0}"
}
# <<< human_history_count END
HIST=$(human_history_count "${ADMIN_TOK:-}")
echo " 历史条数 ${HIST}(同步接口只累加不删除;与切换前对比由人工确认)"
docker rename navi-next navi
trap - ERR
docker rm -f "$PREV" >/dev/null 2>&1 || true
rm -f "$REMOTE_TAR"
cleanup_env_file # 明文令牌的 /tmp env 文件必须删(成功路径)
# 收尾断言:名字必须回到 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