用户决策:scope=self-only,strategy=full-acme(容器内跑完整 acme.sh)。 ## 动机 此前 HomeDash 的证书由外部链路提供(.106 acme.sh → .60 → 云主机), 而容器是**只读**挂载 /etc/nginx/certs(RW=false),续期后没人告诉它的 nginx 重新握手。本 agent 让 HomeDash 自己闭环:签发 → 写数据卷 → reload。 ## 实现 - deploy/cert-agent.sh:supervisord 新增 [program:cert-agent] - 域名从 /app/data/gateway.yml 的 host 提取(或 CERT_DOMAINS 显式指定) - acme.sh 装进**数据卷** /app/data/acme(容器重建不丢续期状态) - install-cert 到 /app/data/certs/,--reloadcmd 设为 nginx -t && nginx -s reload - 续期后比对公钥指纹,变了才 reload(不盲目 reload) - 未配置 CERT_DNS_CRED 时安静退出,行为与现在完全一致 - 证书目录挂载去掉 :ro(自签需要写;此前只读是刻意的保护) - zd-host.sh 的 env 白名单加 CERT_ 前缀,否则升级时凭据传不进新容器 - install-host.sh 同步支持 CERT_DNS_CRED,避免两套脚本行为不一致 - 证书路径改为数据卷 /app/data/certs/(gateway.yml 指向它) ## 测试(沙箱 stub,非真实签发) - 无凭据 → 安静退出、exit 0、不调 acme、不报错 - 有凭据 → 正确从 gateway.yml 提取 2 个域名、识别 dns_ali、 签发参数正确、指纹变化后触发 reload ## 途中修掉自己的两个真 bug ① run_acme 里用 `set -- $DOMAINS` 改函数位置参数,dash 下与后续变量 展开冲突 ② LE_RELOAD 是命令串,直接当命令名执行 → "LE_RELOAD: not found", 容器里 reload 会**静默失效**;改为函数 + eval ## 边界 只管 HomeDash 自己;introduce/sdk.homeagent 等仍走外部链路。 凭据只从环境变量读,不落盘不入库。
224 lines
11 KiB
Bash
Executable File
224 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 首装脚本:在**干净机器**上把 HomeDash 装成统一入口(host 网络单容器)。
|
||
#
|
||
# 与 deploy/zd-host.sh 的分工:
|
||
# install-host.sh 首次安装(本机执行,需要 docker)
|
||
# zd-host.sh 已有安装的升级/回滚(从开发机通过 ssh 推送镜像)
|
||
#
|
||
# 用法(在目标机器上,仓库目录内):
|
||
# NAVI_DOMAIN=example.com NAVI_SYNC_TOKEN=xxx NAVI_ADMIN_TOKEN=yyy bash deploy/install-host.sh
|
||
# 远程执行:
|
||
# ssh root@host 'bash -s' < deploy/install-host.sh
|
||
#
|
||
# 常用环境变量(全部可覆盖):
|
||
# NAVI_SRC 仓库目录 默认:脚本上级目录
|
||
# NAVI_DATA_DIR 数据卷目录 默认:/vol1/docker/navi-data
|
||
# NAVI_CERT_DIR 证书目录 默认:/vol1/docker/cert
|
||
# CERT_DNS_CRED 可选。给了就让容器内的 cert-agent 自签自续(见 README
|
||
# 「容器内自动续签」);不给则证书由外部链路提供。
|
||
# NAVI_TAG 构建出的镜像名 默认:homedash:ui-install
|
||
# NAVI_ENTRY_PORT HTTPS/HTTP 入口端口 默认:3080
|
||
# NAVI_TLS_PORT 额外 TLS 端口 默认:8443
|
||
# NAVI_REDIRECT_PORT HTTP→HTTPS 跳转 默认:3081
|
||
# NAVI_TLS 1=TLS(需证书)0=纯 HTTP 默认:1
|
||
# NAVI_DOMAIN 门户域名(可选,会写入一条门户路由)
|
||
# NAVI_LOGIN_DOMAIN 统一登录域名(可选,默认 login.<NAVI_DOMAIN>)
|
||
# NAVI_SYNC_TOKEN / NAVI_ADMIN_TOKEN 留空则自动生成并打印
|
||
# NAVI_BUILD 1=本机构建 0=用 NAVI_TAR 导入 默认:1
|
||
# NAVI_TAR 已构建好的镜像 tar(NAVI_BUILD=0 时必填)
|
||
set -euo pipefail
|
||
|
||
SRC="${NAVI_SRC:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||
DATA_DIR="${NAVI_DATA_DIR:-/vol1/docker/navi-data}"
|
||
CERT_DIR="${NAVI_CERT_DIR:-/vol1/docker/cert}"
|
||
TAG="${NAVI_TAG:-homedash:ui-install}"
|
||
ENTRY_PORT="${NAVI_ENTRY_PORT:-3080}"
|
||
TLS_PORT="${NAVI_TLS_PORT:-8443}"
|
||
REDIRECT_PORT="${NAVI_REDIRECT_PORT:-3081}"
|
||
TLS="${NAVI_TLS:-1}"
|
||
DOMAIN="${NAVI_DOMAIN:-}"
|
||
LOGIN_DOMAIN="${NAVI_LOGIN_DOMAIN:-}"
|
||
SYNC_TOKEN="${NAVI_SYNC_TOKEN:-}"
|
||
ADMIN_TOKEN="${NAVI_ADMIN_TOKEN:-}"
|
||
BUILD="${NAVI_BUILD:-1}"
|
||
TAR="${NAVI_TAR:-}"
|
||
CERT_FILE="/etc/nginx/certs/fullchain.pem"
|
||
KEY_FILE="/etc/nginx/certs/privkey.pem"
|
||
|
||
die() { echo "!! $*" >&2; exit 1; }
|
||
step() { echo "== $* =="; }
|
||
|
||
# ── 1. 前置检查 ──
|
||
step "1/6 前置检查"
|
||
command -v docker >/dev/null || die "需要 docker(本脚本在目标机器上执行)"
|
||
docker info >/dev/null 2>&1 || die "docker 不可用(权限或 daemon 未启动)"
|
||
[ -f "$SRC/Dockerfile" ] || die "找不到 $SRC/Dockerfile(用 NAVI_SRC 指定仓库目录)"
|
||
|
||
if docker inspect navi >/dev/null 2>&1; then
|
||
die "已存在名为 navi 的容器:这是一次升级,请用 deploy/zd-host.sh <版本>;如确要重装先 docker rm -f navi"
|
||
fi
|
||
|
||
for p in "$ENTRY_PORT" "$REDIRECT_PORT" "$TLS_PORT"; do
|
||
if ss -H -lnt 2>/dev/null | awk '{print $4}' | grep -q ":$p\$"; then
|
||
die "端口 $p 已被占用(统一入口需要独占;用 NAVI_*_PORT 换端口)"
|
||
fi
|
||
done
|
||
|
||
mkdir -p "$DATA_DIR" || die "无法创建数据目录 $DATA_DIR"
|
||
[ -d "$CERT_DIR" ] || mkdir -p "$CERT_DIR"
|
||
if [ "$TLS" = "1" ]; then
|
||
[ -s "$CERT_DIR/fullchain.pem" ] || die "缺少证书 $CERT_DIR/fullchain.pem(或用 NAVI_TLS=0 走纯 HTTP)"
|
||
[ -s "$CERT_DIR/privkey.pem" ] || die "缺少私钥 $CERT_DIR/privkey.pem"
|
||
fi
|
||
echo " 数据目录 $DATA_DIR"
|
||
echo " 证书目录 $CERT_DIR(TLS=$TLS)"
|
||
echo " 入口 ${ENTRY_PORT}$([ "$TLS" = 1 ] && echo " + ${TLS_PORT}" || echo ""),跳转 ${REDIRECT_PORT}"
|
||
|
||
# ── 2. 令牌 ──
|
||
step "2/6 令牌"
|
||
if [ -z "$SYNC_TOKEN" ]; then SYNC_TOKEN="$(openssl rand -hex 20 2>/dev/null || head -c 20 /dev/urandom | od -An -tx1 | tr -d ' \n')"; fi
|
||
if [ -z "$ADMIN_TOKEN" ]; then ADMIN_TOKEN="$(openssl rand -hex 20 2>/dev/null || head -c 20 /dev/urandom | od -An -tx1 | tr -d ' \n')"; fi
|
||
echo " 已就绪(只在这里打印一次,请自行保存)"
|
||
|
||
# ── 3. 构建镜像 ──
|
||
step "3/6 镜像"
|
||
if [ "$BUILD" = "1" ]; then
|
||
docker build -t "$TAG" -f "$SRC/Dockerfile" "$SRC"
|
||
else
|
||
[ -n "$TAR" ] && [ -s "$TAR" ] || die "NAVI_BUILD=0 需要 NAVI_TAR=<镜像 tar>"
|
||
docker load -i "$TAR"
|
||
fi
|
||
docker image inspect "$TAG" >/dev/null 2>&1 || die "镜像 $TAG 不存在(构建失败或 tar 里是别的 tag)"
|
||
|
||
# ── 4. 初始网关配置 ──
|
||
step "4/6 初始网关配置"
|
||
# 注意:路由表源文件是 <数据卷>/gateway.yml(容器按此读取);
|
||
# <数据卷>/gateway/ 只放生成产物 gateway.conf。写错位置会被静默当成空路由表。
|
||
GW_YML="$DATA_DIR/gateway.yml"
|
||
mkdir -p "$DATA_DIR/gateway"
|
||
if [ -s "$GW_YML" ]; then
|
||
echo " 已存在,保留不动:$GW_YML"
|
||
else
|
||
{
|
||
echo "listenPort: $ENTRY_PORT"
|
||
echo "listenTlsPort: $TLS_PORT"
|
||
echo "redirectPort: $REDIRECT_PORT"
|
||
echo "listenHttps: $([ "$TLS" = 1 ] && echo true || echo false)"
|
||
# 对外端口=入口端口的部署(全新安装通常如此),跳转要带上端口;
|
||
# 若前面有 frp/nginx 把 443 映射到本入口端口,请删掉这一行。
|
||
if [ "$TLS" = "1" ] && [ "$ENTRY_PORT" != "443" ]; then
|
||
echo "redirectWithPort: true"
|
||
fi
|
||
if [ "$TLS" = 1 ]; then
|
||
echo "certFile: $CERT_FILE"
|
||
echo "keyFile: $KEY_FILE"
|
||
fi
|
||
echo "authUrl: http://127.0.0.1:3005/auth/verify"
|
||
if [ -n "$LOGIN_DOMAIN" ]; then
|
||
echo "loginUrl: https://$LOGIN_DOMAIN/login"
|
||
elif [ -n "$DOMAIN" ]; then
|
||
echo "loginUrl: https://login.$DOMAIN/login"
|
||
fi
|
||
echo "routes:"
|
||
if [ -n "$DOMAIN" ]; then
|
||
echo " - host: $DOMAIN"
|
||
echo " kind: portal"
|
||
echo " note: 门户(首装生成)"
|
||
fi
|
||
if [ -n "$LOGIN_DOMAIN" ]; then
|
||
echo " - host: $LOGIN_DOMAIN"
|
||
echo " kind: auth"
|
||
echo " note: 统一登录页"
|
||
elif [ -n "$DOMAIN" ]; then
|
||
echo " - host: login.$DOMAIN"
|
||
echo " kind: auth"
|
||
echo " note: 统一登录页"
|
||
fi
|
||
} > "$GW_YML"
|
||
echo " 已生成 $GW_YML($([ -n "$DOMAIN" ] && echo "含门户路由 $DOMAIN" || echo "暂无路由,装完在后台添加"))"
|
||
fi
|
||
|
||
# ── 5. 启动容器 ──
|
||
step "5/6 启动容器(host 网络)"
|
||
BASE_URL="${SEARXNG_BASE_URL:-}"
|
||
if [ -z "$BASE_URL" ]; then
|
||
if [ -n "$DOMAIN" ]; then BASE_URL="https://$DOMAIN/"; else BASE_URL="http://127.0.0.1:${ENTRY_PORT}/"; fi
|
||
fi
|
||
docker run -d --name navi --network host --restart unless-stopped \
|
||
-v "${DATA_DIR}:/app/data" -v "${CERT_DIR}:/etc/nginx/certs" \
|
||
-e NAVI_SYNC_TOKEN="$SYNC_TOKEN" -e NAVI_ADMIN_TOKEN="$ADMIN_TOKEN" \
|
||
-e SEARXNG_BASE_URL="$BASE_URL" \
|
||
$([ -n "$DOMAIN" ] && echo "-e PORTAL_COOKIE_DOMAIN=.$DOMAIN") \
|
||
$([ -n "$DOMAIN" ] && echo "-e PORTAL_HOME_URL=https://$DOMAIN/") \
|
||
$([ -n "$DOMAIN" ] && [ -n "${CERT_DNS_CRED:-}" ] && echo "-e CERT_DNS_API=dns_ali -e CERT_DNS_CRED=$CERT_DNS_CRED -e CERT_DOMAINS=$DOMAIN") \
|
||
"$TAG" >/dev/null
|
||
echo " 容器已启动,等待健康检查…"
|
||
for i in $(seq 1 60); do
|
||
if curl -fs -m 3 http://127.0.0.1:18080/healthz >/dev/null 2>&1; then echo " healthz 就绪(${i}s)"; break; fi
|
||
[ "$i" = 60 ] && { docker logs --tail 40 navi >&2; die "健康检查超时"; }
|
||
sleep 1
|
||
done
|
||
|
||
# ── 6. 应用网关配置 + 自检 ──
|
||
step "6/6 应用配置与自检"
|
||
APPLY="$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:3005/api/gateway/apply || true)"
|
||
echo " apply: $APPLY"
|
||
case "$APPLY" in *applied*) ;; *) docker logs --tail 40 navi >&2; die "网关配置未通过校验(上面有 nginx 报错原文)";; esac
|
||
|
||
code_of() { curl -sk -o /dev/null -w '%{http_code}' -m 8 "$@" 2>/dev/null || true; }
|
||
|
||
# apply 会 reload nginx:等入口监听真正就绪再探测,避免把"还没起来"误判成失败
|
||
for i in $(seq 1 20); do
|
||
code="$(code_of "http://127.0.0.1:${ENTRY_PORT}/" )"
|
||
[ "$code" != "000" ] && break
|
||
sleep 0.5
|
||
done
|
||
|
||
# 字段名写错会被 YAML 静默忽略(真实踩过:listenHTTPS vs listenHttps),
|
||
# 导致"HTTPS 端口"其实是明文,而跳转端口又 302 到它 → 浏览器必报错。
|
||
if [ "$TLS" = "1" ]; then
|
||
grep -q 'ssl_certificate' "$DATA_DIR/gateway/gateway.conf" \
|
||
|| die "生成的配置里没有 ssl_certificate:TLS 未生效(检查 gateway.yml 字段名与证书路径)"
|
||
echo " ✓ TLS 已生效(配置含 ssl_certificate)"
|
||
fi
|
||
|
||
fail=0
|
||
check() { # 名称 期望码 实际码
|
||
if [ "$2" = "$3" ]; then echo " ✓ $1 → $3"; else echo " ✗ $1 → $3(期望 $2)"; fail=1; fi
|
||
}
|
||
SCHEME=$([ "$TLS" = 1 ] && echo https || echo http)
|
||
check "后台 /admin" 200 "$(code_of "http://127.0.0.1:18080/admin")"
|
||
check "内部配置 /conf.yml" 200 "$(code_of "http://127.0.0.1:18080/conf.yml")"
|
||
check "未知域名 → 自定义 404" 404 "$(code_of -k --resolve nobody.invalid:${ENTRY_PORT}:127.0.0.1 "${SCHEME}://nobody.invalid:${ENTRY_PORT}/")"
|
||
if [ -n "$DOMAIN" ]; then
|
||
check "门户 $DOMAIN" 200 "$(code_of -k --resolve "${DOMAIN}:${ENTRY_PORT}:127.0.0.1" "${SCHEME}://${DOMAIN}:${ENTRY_PORT}/")"
|
||
fi
|
||
if [ "$TLS" = "1" ]; then
|
||
# TLS 路径自检:跳转端口必须 302 到 https,且入口要能协商 HTTP/2
|
||
# (曾踩过:nginx 1.22 不认独立的 http2 on;,必须写成 listen … ssl http2;)
|
||
check "http→https 跳转" 302 "$(code_of --resolve "nobody.invalid:${REDIRECT_PORT}:127.0.0.1" "http://nobody.invalid:${REDIRECT_PORT}/")"
|
||
HV="$(curl -sk -o /dev/null -w '%{http_version}' -m 8 "${SCHEME}://127.0.0.1:${ENTRY_PORT}/" 2>/dev/null || echo '?')"
|
||
if [ "$HV" = "2" ] || [ "$HV" = "2.0" ]; then echo " ✓ HTTP/2 协商 → $HV"; else echo " ! HTTP 版本为 $HV(HTTP/2 与客户端支持有关,仅提示)"; fi
|
||
CERT_SUBJ="$(echo | openssl s_client -connect 127.0.0.1:${ENTRY_PORT} -servername "${DOMAIN:-localhost}" 2>/dev/null | openssl x509 -noout -subject 2>/dev/null || true)"
|
||
echo " 证书: ${CERT_SUBJ:-未能读取(仅提示)}"
|
||
fi
|
||
|
||
echo " 容器状态:$(docker inspect navi --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}')"
|
||
|
||
[ "$fail" = 0 ] || die "自检未全绿,请按上面 ✗ 项排查(容器保留未删除,便于查看日志)"
|
||
|
||
cat <<EOF
|
||
|
||
首装完成。
|
||
后台地址 http://127.0.0.1:18080/admin (外网:${SCHEME}://<域名>:${ENTRY_PORT}/admin)
|
||
统一登录 ${LOGIN_DOMAIN:-login.${DOMAIN:-<你的域名>}}
|
||
NAVI_SYNC_TOKEN=$SYNC_TOKEN
|
||
NAVI_ADMIN_TOKEN=$ADMIN_TOKEN
|
||
|
||
下一步:
|
||
1. 后台「服务转发」添加各服务域名,逐条勾选"统一认证";「统一登录账号」设置门户用户名/密码
|
||
2. 域名解析到本机 ${ENTRY_PORT}(TLS 时同时开放 ${TLS_PORT};${REDIRECT_PORT} 用于 http 跳 https)
|
||
3. 后台「站点信息」可改门户页/登录页文案、备案号、壁纸
|
||
4. 升级:在开发机执行 deploy/zd-host.sh <版本>(会保留 navi-prev-* 回滚点)
|
||
EOF
|