Files
HomeDash/deploy/zd.sh
HomeDash Bot 53beff646a feat(admin): 现有配置导入 + 添加表单补齐认证/导航开关 + 统一登录账号设置 + 设置分组
针对反馈的五个问题:

1. 后台"没有映射":新增 /admin/api/gateway/import 与界面「从现有 nginx 配置导入」,
   把老统一入口的 nginx 配置贴进来即可解析出全部「域名 → 后端」初稿
   (含 requireLogin / 免认证路径 / SSE 流式 / 备案注入 / 自签证书 / auth 与 portal 类型)。
   解析只读不落盘,确认后才保存;导入默认不接管导航,避免一导入就改掉手工导航。
2. 添加转发时没有导航选项:添加表单补齐「同时加入 Dashy 导航 / 分组 / 名称 / 描述 /
   图标 / 自动抓取」。
3. 添加转发时没有 portal 保护选项:添加表单补齐「启用 portal 统一登录保护」,
   并补上 WebSocket 透传与上传上限。
4. 设置里无法配置 portal 账号:新增统一登录账号卡片,账号存数据卷
   (portal-auth.json,只存加盐哈希),优先级 数据卷 > 环境变量,
   改完立即生效、无需重新部署;会话校验按当前用户名,改账号后旧会话失效。
5. 设置项太乱:按用途分组(站点与外观 / 导航内容 / 历史记录 / 转发与统一登录 / 高级),
   外观卡片并入站点组。

另修两个本轮引入的缺陷:
- 容器假 unhealthy:内部端口改 18080 后,基础镜像的 HEALTHCHECK 仍指向 8080;
  部署脚本生成的 Dockerfile 现在显式覆盖为 :18080/healthz。
- kind=auth 不依赖 portal-auth 容器,指向内置登录服务;避免生成空 proxy_pass。

测试:nginx 配置解析(含兜底段忽略、多域名段、类型判定、默认不接管导航)、
垃圾输入报错、账号存储(不明文、可校验、改名后旧口令失效)、auth 类型生成。
2026-09-11 22:00:09 +08:00

180 lines
8.7 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 地址,默认 admin@10.0.0.10
# 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)"
HOST="${NAVI_HOST:-admin@10.0.0.10}"
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