Files
HomeDash/deploy/zd.sh
HomeDash Bot b99319b38b chore(sanitize): 备案号与域名一律可配置,仓库不再预置真实域名/备案号/内网地址
用户要求:备案号与域名都应当可配置,仓库内容要脱敏。

功能(不只是替换字面量):
- 被代理页面注入的备案号改为读后台「站点信息」的 icp/gongan,**未配置就不注入**;
  原先写死具体备案号,等于每个部署者都会把别人的备案信息印到自己页面上
- 注入内容做 nginx 安全处理(去引号/反斜杠/换行/美元符),并有测试覆盖
- auth.go:PORTAL_COOKIE_DOMAIN / PORTAL_HOME_URL 不再预置域名,
  cookie 默认 host-only、首页默认相对路径 "/"
- gateway.go:默认 loginUrl 不再预置域名(受保护路由缺配置会明确报错,不静默生成跳不出去的登录页)
- 首装脚本在给了 NAVI_DOMAIN 时自动注入 PORTAL_COOKIE_DOMAIN/PORTAL_HOME_URL,
  避免"去掉写死默认值"导致跨子域单点登录失效

脱敏:内网 IP(10.0.0.10)与真实备案号清零;真实域名仅剩门户页内容 8 处(待定);
测试夹具/示例/错误页/脚本/后台占位提示全部通用化。

README:新增「跨子域单点登录需要的两个环境变量」;环境变量表改为 NAVI_HOST 必填。
测试:新增备案号注入三条(用配置、未配置不注入、危险字符清理)。
2026-09-12 10:10:52 +08:00

182 lines
8.8 KiB
Bash
Executable File
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 零停机部署
#
# 为什么需要它:直接 docker stop/rename/run 会有十几秒完全不可用,而且新容器
# nginx 先就绪、SearXNG 还在启动,这期间 /search 返回 502。本脚本先在对侧端口
# 把新容器验证到底(含 /search,顺带预热 SearXNG),再改门户 nginx 上游并
# reload(瞬时切换),最后才停旧容器并保留为回滚点。
#
# 用法:
# deploy/zd.sh <version> 例: deploy/zd.sh v29
#
# 环境变量(默认值即本项目生产环境,可覆盖):
# NAVI_HOST 目标机 ssh 地址(必填)
# NAVI_SSH_PASS 目标机密码(无 ssh key 时必填)
# NAVI_UPSTREAM_HOST 门户 nginx 里 dashy 域名对应的主机名,默认 dashy.example.com
# NAVI_PORTAL_CONF 门户 nginx 配置文件绝对路径
# NAVI_DATA_DIR 数据卷宿主机路径
# NAVI_BASE_IMAGE 构建基础镜像,默认 jianf-navi:latest
#
# 失败会自动回滚:切上游前失败只清掉新容器(老容器继续服务);切上游后失败
# 会把配置还原并 reload。每次改动门户配置前都会先备份。
set -euo pipefail
VER="${1:?用法: deploy/zd.sh <version>}"
TAG="homedash:ui-${VER}"
STAMP="$(date +%Y%m%d%H%M%S)"
[ -n "$HOST" ] || { echo "!! 请指定目标机:NAVI_HOST=user@host bash $0 ..." >&2; exit 2; }
HOST="${NAVI_HOST:-}" # 必填:NAVI_HOST=user@host
SSH_PASS="${NAVI_SSH_PASS:-}"
UPSTREAM_HOST="${NAVI_UPSTREAM_HOST:-dashy.example.com}"
PORTAL_CONF="${NAVI_PORTAL_CONF:-/vol1/docker/portal/nginx/nginx.conf}"
DATA_DIR="${NAVI_DATA_DIR:-/vol1/docker/navi-data}"
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 还指向旧的 8080;这里必须覆盖,
# 否则容器会一直显示 unhealthy(功能正常但健康信号是假的)。
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' STAMP='$STAMP' REMOTE_TAR='$REMOTE_TAR' VER='$VER' \
PORTAL_CONF='$PORTAL_CONF' UPSTREAM_HOST='$UPSTREAM_HOST' DATA_DIR='$DATA_DIR' bash -s" <<'REMOTE'
set -euo pipefail
CUR_PORT=$(grep -oP "${UPSTREAM_HOST//./\\.}\\s+http://127\\.0\\.0\\.1:\\K[0-9]+" "$PORTAL_CONF" | head -1)
[ -n "$CUR_PORT" ] || { echo "!! 读不到门户上游端口" >&2; exit 1; }
if [ "$CUR_PORT" = "3003" ]; then NEW_PORT=3004; else NEW_PORT=3003; fi
echo " 上游端口 ${CUR_PORT} → 新端口 ${NEW_PORT}"
CONF_BAK="${PORTAL_CONF}.bak-${STAMP}"
cp -a "$PORTAL_CONF" "$CONF_BAK"
gzip -dc "$REMOTE_TAR" | docker load >/dev/null
# 记录切换前的历史条数,切换后用于对比(同步接口只累加不删除,
# 所以"变少"就是异常;写死条数则会把用户自己删记录误判成失败)
OLD_HIST=$(curl -s "http://127.0.0.1:${CUR_PORT}/admin/api/history" 2>/dev/null | grep -o '"url"' | wc -l)
OLD_HIST=${OLD_HIST:-0}
docker inspect navi --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|DEBIAN_FRONTEND)=' > "/tmp/navi-${STAMP}.env"
flipped=0
on_err() {
rc=$?
echo "!! 失败(rc=$rc),回滚" >&2
if [ "$flipped" = "1" ]; then
cp -a "$CONF_BAK" "$PORTAL_CONF"
docker exec portal-nginx nginx -t && docker exec portal-nginx nginx -s reload || true
fi
docker rm -f navi-next >/dev/null 2>&1 || true
exit $rc
}
trap on_err ERR
docker rm -f navi-next >/dev/null 2>&1 || true
docker run -d --name navi-next --restart unless-stopped --network bridge \
-p "${NEW_PORT}:18080" --env-file "/tmp/navi-${STAMP}.env" \
-v "${DATA_DIR}:/app/data" "homedash:ui-${VER}" >/dev/null
w200() { for _ in $(seq 1 90); do [ "$(curl -s -o /dev/null -w '%{http_code}' "$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:${NEW_PORT}/healthz"
w200 "http://127.0.0.1:${NEW_PORT}/conf.yml"
w200 "http://127.0.0.1:${NEW_PORT}/admin"
w200 "http://127.0.0.1:${NEW_PORT}/navi.js"
# /search 会真正跑一次搜索:既确认 SearXNG 可用,也顺带把它预热,
# 这样切过去之后用户不会撞上"nginx 就绪但 SearXNG 还在启动"的 502。
w200 "http://127.0.0.1:${NEW_PORT}/search?q=linux"
HIST=$(curl -s "http://127.0.0.1:${NEW_PORT}/admin/api/history" | grep -o '"url"' | wc -l)
[ "$HIST" -ge "$OLD_HIST" ] || { echo "!! 历史条数从 $OLD_HIST 掉到 $HIST(疑似丢数据)" >&2; false; }
echo " 新容器全绿(历史 ${OLD_HIST} → ${HIST} 条),开始切换上游"
sed "s#\\(${UPSTREAM_HOST//./\\.}\\s*http://127\\.0\\.0\\.1:\\)${CUR_PORT};#\\1${NEW_PORT};#" "$PORTAL_CONF" > /tmp/portal.conf.new
# 关键:必须同 inode 覆写(cat > 而不是 sed -i / mv)。
# 该文件是以单文件 bind mount 挂进 portal-nginx 的,且挂载是只读的;
# 用 sed -i / mv 会换新 inode,容器仍指向旧(孤儿)inode,reload 读到的还是旧端口
# → 上游被切走而新端口无人应答 → 全站 502(本项目真实踩过)。
cat /tmp/portal.conf.new > "$PORTAL_CONF"
grep -q "${UPSTREAM_HOST} *http://127.0.0.1:${NEW_PORT};" "$PORTAL_CONF" || { echo "!! 宿主机配置未改写" >&2; false; }
# 容器看不到新值时(历史上被 sed -i 换过 inode)只能重启它重新绑定,
# 这会断开所有域名约 1 秒,所以先走 reload 路径。
if ! docker exec portal-nginx grep -q "127.0.0.1:${NEW_PORT};" /etc/nginx/nginx.conf; then
echo " 容器未看到新配置(inode 错位)→ 重启 portal-nginx"
docker restart portal-nginx >/dev/null
sleep 4
fi
docker exec portal-nginx grep -q "127.0.0.1:${NEW_PORT};" /etc/nginx/nginx.conf \
|| { echo "!! 容器内配置仍是旧端口" >&2; false; }
docker exec portal-nginx nginx -t
docker exec portal-nginx nginx -s reload
sleep 2
flipped=1
# 切后哨兵:经门户访问不得是 502/503/504(未登录正常是 302)。
# 这是"上游切走但新端口无人应答"唯一能看到的现象。
CODE=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: ${UPSTREAM_HOST}" http://127.0.0.1/conf.yml)
case "$CODE" in
502|503|504) echo "!! 门户访问返回 $CODE(上游不可达)" >&2; false ;;
esac
echo " 上游已切到 ${NEW_PORT}(门户探测 $CODE)"
w200 "http://127.0.0.1:${NEW_PORT}/admin"
docker stop navi >/dev/null
docker rename navi "navi-prev-${STAMP}"
docker rename navi-next navi
trap - ERR
for n in $(docker ps -a --filter name=navi-prev --format '{{.Names}}'); do
case "$n" in *"$STAMP"*) ;; *) docker rm -f "$n" >/dev/null;; esac
done
rm -f "$REMOTE_TAR"
echo " 配置备份=$CONF_BAK 回滚点=navi-prev-${STAMP}"
echo "DEPLOY_OK tag=homedash:ui-${VER} port=${NEW_PORT} health=$(docker inspect navi --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}')"
REMOTE