Files
HomeDash/deploy/install-host.sh
JianFeeeee e7578aa607 feat(cert): cert-agent —— 容器内自签自续 HomeDash 自己的证书
用户决策: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 等仍走外部链路。
凭据只从环境变量读,不落盘不入库。
2026-10-04 08:02:41 +08:00

224 lines
11 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 装成统一入口(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