#!/usr/bin/env bash # ============================================================================= # clover 三叶草邮局 — 一键安装器 v2.0.2 # # bash install.sh # 交互式安装(推荐) # bash install.sh --non-interactive # 从环境变量读参数(自动化/CI) # bash install.sh --dry-run # 只打印将要执行的动作,不做任何改动 # bash install.sh --help # # 非交互模式需要通过环境变量提供的参数: # PANEL_HOSTNAME(必填);邮件域名/主机名可登录面板后设置 # MAIL_DOMAIN MAIL_HOSTNAME ACME_EMAIL(可选) TZ # CLOVER_CF_API_TOKEN(可选) # # 目标平台:Debian 11–13 / Ubuntu 22.04+,x86_64/arm64,最低 2 核 2GB RAM + 1GB Swap;推荐 4GB RAM # ============================================================================= set -euo pipefail CLOVER_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$CLOVER_HOME" if [[ -t 1 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != dumb ]]; then GREEN=$'\033[1;32m'; YELLOW=$'\033[1;33m'; RED=$'\033[1;31m' CYAN=$'\033[1;36m'; DIM=$'\033[2m'; NC=$'\033[0m' else GREEN=''; YELLOW=''; RED=''; CYAN=''; DIM=''; NC='' fi say() { printf "${GREEN}[clover]${NC} %s\n" "$*"; } warn() { printf "${YELLOW}[clover]${NC} %s\n" "$*"; } die() { printf "${RED}[clover] 错误:%s${NC}\n" "$*" >&2; exit 1; } step() { printf "\n${CYAN}== %s ==${NC}\n" "$*"; } DRY_RUN=0 NON_INTERACTIVE=0 for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=1 ;; --non-interactive|-y) NON_INTERACTIVE=1 ;; --help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) die "未知参数:$arg(--help 查看用法)" ;; esac done # 所有有副作用的命令都经 run/run_sh,dry-run 下只打印 run() { if [ "$DRY_RUN" -eq 1 ]; then printf "${DIM} [dry-run] %s${NC}\n" "$*" else printf "${DIM} \$ %s${NC}\n" "$*" "$@" fi } run_sh() { if [ "$DRY_RUN" -eq 1 ]; then printf "${DIM} [dry-run] %s${NC}\n" "$*" else printf "${DIM} \$ %s${NC}\n" "$*" bash -c "$*" fi } printf "${CYAN}" cat <<'EOF' ╭──────────────────────────────────────────╮ │ 🍀 clover 三叶草邮局 · 一键安装 v2.0.2 │ │ 自托管 Docker 邮局 + DNS 一键推送 CF │ ╰──────────────────────────────────────────╯ EOF printf "${NC}" [ "$DRY_RUN" -eq 1 ] && warn "DRY-RUN 模式:只打印动作,不做任何改动" [ "$NON_INTERACTIVE" -eq 1 ] && say "非交互模式:参数取自环境变量" if [ -f .env ]; then die "检测到已有 Clover 配置(${CLOVER_HOME}/.env)。如需覆盖安装或清空旧数据,请重新运行 bootstrap.sh 并选择操作;升级请从面板检查更新,或手动运行 clover update。" fi # --------------------------------------------------------------------------- # 1. 环境检查 # --------------------------------------------------------------------------- step "1/6 环境检查" if [ "$(id -u)" -ne 0 ]; then [ "$DRY_RUN" -eq 1 ] || die "请以 root 运行(绑定 25/80/443 端口与管理 Docker 需要)" warn "非 root(dry-run 继续)" fi if [ -r /etc/os-release ]; then . /etc/os-release case "${ID:-}" in debian|ubuntu) say "系统:${PRETTY_NAME}" ;; *) [ "$DRY_RUN" -eq 1 ] && warn "非 Debian/Ubuntu(${PRETTY_NAME:-unknown})——dry-run 继续" || die "仅支持 Debian / Ubuntu(当前:${PRETTY_NAME:-unknown})" ;; esac else [ "$DRY_RUN" -eq 1 ] && warn "本机无 /etc/os-release(非 Linux)——dry-run 继续" || die "无法读取 /etc/os-release,仅支持 Debian/Ubuntu" fi ARCH="$(dpkg --print-architecture 2>/dev/null || uname -m)" case "$ARCH" in x86_64) ARCH=amd64 ;; aarch64) ARCH=arm64 ;; esac case "$ARCH" in amd64|arm64) say "架构:${ARCH}" ;; *) [ "$DRY_RUN" -eq 1 ] && warn "架构 ${ARCH} 非 amd64/arm64——dry-run 继续" || die "仅支持 amd64 / arm64(当前:${ARCH})" ;; esac K_MAJOR="$(uname -r | cut -d. -f1)"; K_MINOR="$(uname -r | cut -d. -f2 | cut -d- -f1)" if [ "${K_MAJOR:-0}" -lt 5 ] || { [ "${K_MAJOR:-0}" -eq 5 ] && [ "${K_MINOR:-0}" -lt 10 ]; }; then warn "内核 $(uname -r) 偏老(建议 ≥ 5.10):容器共享宿主内核,可能 Kernel too old / 崩溃" else say "内核:$(uname -r)" fi MEM_MB="$(awk '/MemTotal/{printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)" if [ "${MEM_MB}" -lt 3500 ]; then warn "内存 ${MEM_MB}MB 偏小(建议 ≥ 4GB,ClamAV/rspamd 吃内存;可在 .env 关 ENABLE_CLAMAV=0)" else say "内存:${MEM_MB}MB" fi DISK_GB="$(df -BG --output=avail / 2>/dev/null | tail -1 | tr -dc '0-9' || echo 0)" if [ "${DISK_GB:-0}" -lt 15 ]; then warn "根分区可用 ${DISK_GB}GB 偏小(建议 ≥ 15GB,邮件数据会增长)"; else say "磁盘可用:${DISK_GB}GB"; fi # Docker / compose if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then say "Docker:$(docker --version | awk '{print $3}' | tr -d ,) · $(docker compose version --short 2>/dev/null || echo v2)" elif [ "$DRY_RUN" -eq 1 ]; then warn "未检测到 Docker + Compose v2(dry-run 继续,计划执行安装)" run_sh "curl -fsSL https://get.docker.com | sh" run_sh "systemctl enable --now docker" elif [ "$NON_INTERACTIVE" -eq 1 ]; then die "未安装 Docker Engine ≥24 + Compose v2(非交互模式不自动安装,请先装好)" else warn "未检测到 Docker + Compose v2" reply="y" if [ -r /dev/tty ]; then printf "是否现在用官方脚本安装 Docker?[Y/n]: " IFS= read -r reply < /dev/tty || reply="y" fi case "${reply:-y}" in y|Y|yes|"") run_sh "curl -fsSL https://get.docker.com | sh" run_sh "systemctl enable --now docker" ;; *) die "请手动安装 Docker 后重跑:https://docs.docker.com/engine/install/debian/" ;; esac fi # 端口占用(80/443 必须空闲;25 需空闲) port_in_use() { local p="$1" if command -v ss >/dev/null 2>&1; then ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}$" elif command -v netstat >/dev/null 2>&1; then netstat -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}$" else return 1 fi } nginx_owns_port() { local p="$1" owners owners="$(ss -H -ltnp "sport = :${p}" 2>/dev/null || true)" [ -n "$owners" ] || return 1 printf '%s\n' "$owners" | grep -q nginx || return 1 ! printf '%s\n' "$owners" | grep -qv nginx } aapanel_nginx_active() { local init=/etc/init.d/nginx bin=/www/server/nginx/sbin/nginx local config=/www/server/nginx/conf/nginx.conf pidfile=/www/server/nginx/logs/nginx.pid local pid cmdline [ -f "$init" ] && [ -x "$bin" ] && [ -f "$config" ] && [ -f "$pidfile" ] || return 1 # aaPanel versions vary the quoting and variable expansion in this SysV # script. The canonical paths, live master process and socket owners are # stronger evidence than matching specific assignment text. grep -Fq '/www/server/nginx/' "$init" || return 1 pid="$(cat "$pidfile" 2>/dev/null || true)" [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null || return 1 cmdline="$(tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true)" [[ "$cmdline" == *"$bin"* ]] || return 1 # An on-disk vhost may refer to a certificate that has not been issued yet. # In that state `nginx -T` fails even while the live aaPanel master owns both # public sockets. Detect the running listener instead of rejecting that host. nginx_owns_port 80 && nginx_owns_port 443 } for p in 80 443 25; do if port_in_use "$p"; then warn "端口 ${p} 已被占用——如果占用者是旧的 web 服务(nginx/apache)请先停掉(证书签发/收信需要它)" else say "端口 ${p}:空闲" fi done # 已有 systemd Caddy 或 nginx 可保留为宿主入口。 SHARED_NGINX=0 SHARED_CADDY=0 HOST_CADDY_CONFIG="" HOST_CADDY_BIN="" if port_in_use 80 || port_in_use 443; then if command -v python3 >/dev/null 2>&1; then host_caddy="$(python3 scripts/host-caddy.py detect 2>/dev/null || true)" if [ -n "$host_caddy" ]; then HOST_CADDY_CONFIG="${host_caddy%%|*}" HOST_CADDY_BIN="${host_caddy#*|}" SHARED_CADDY=1 say "检测到宿主机 systemd Caddy:保留现有站点,Clover Docker Caddy 改走本机内部端口" fi fi if [ "$SHARED_CADDY" -eq 0 ]; then nginx_managed=0 nginx_controller="" if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet nginx; then nginx_managed=1 nginx_controller="systemd nginx" elif aapanel_nginx_active; then nginx_managed=1 nginx_controller="aaPanel nginx" fi if [ "$nginx_managed" -eq 1 ]; then nginx_80=0; nginx_443=0 if port_in_use 80 && nginx_owns_port 80; then nginx_80=1; fi if port_in_use 443 && nginx_owns_port 443; then nginx_443=1; fi if { ! port_in_use 80 || [ "$nginx_80" -eq 1 ]; } \ && { ! port_in_use 443 || [ "$nginx_443" -eq 1 ]; }; then SHARED_NGINX=1 say "检测到宿主机 ${nginx_controller}:将保留 nginx,Caddy 改走本机内部端口,证书签发期间会短暂停 nginx" else die "80/443 端口还被非 nginx 程序占用,或无法确认监听进程归属。请先释放对应端口后重跑。" fi elif port_in_use 80 || port_in_use 443; then die "80/443 端口被占用,但未识别到可安全复用的 systemd Caddy、systemd Nginx 或 aaPanel Nginx。请确认端口用途并先释放后重跑。" fi fi fi # 出站 25(发信生死线) if timeout 4 bash -c '(exec 3<>/dev/tcp/smtp.gmail.com/25)' 2>/dev/null; then say "出站 25 端口:通" else warn "出站 25 探测不通:部分 VPS 商默认封锁,发信会被拒;需开工单放行(见 docs/m0-checklist.md)" fi VPS_IP="${CLOVER_VPS_IP:-}" if [ -z "$VPS_IP" ]; then VPS_IP="$(curl -fsSL --max-time 8 https://api.ipify.org 2>/dev/null || true)" fi if [ -z "$VPS_IP" ]; then VPS_IP="$(curl -fsSL --max-time 8 https://ifconfig.me 2>/dev/null || true)" fi if [ -z "$VPS_IP" ]; then VPS_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" fi [ -n "$VPS_IP" ] && say "公网 IP:${VPS_IP}" || warn "公网 IP 自动检测失败,稍后可在面板设置页手填" # --------------------------------------------------------------------------- # 2. 收集参数 # --------------------------------------------------------------------------- step "2/6 参数收集" cat <<'EOF' 安装前请先为面板域名添加 DNS A 记录,指向本机公网 IP,并等待解析生效。 面板 Caddy 会自动申请 HTTPS 证书;邮件证书使用 HTTP-01,公网 80 端口必须可达,Cloudflare 代理在签发时需关闭(DNS only)。 若宿主机 nginx 占用 80/443,安装器会自动暂停 nginx 完成签发,再恢复并配置反向代理。 若 systemd 管理的宿主机 Caddy 已占用 80/443,安装器会保留其现有站点并添加 Clover 反代;面板证书由宿主机 Caddy 管理。 面板域名格式示例:clover.example.com(将 example.com 替换成你自己的域名)。 邮件主域名、邮件主机名及 Cloudflare 连接可在登录面板后配置。 EOF ask() { # ask <变量名> <提示> <默认值> local __var="$1" prompt="$2" default="$3" reply="" read_ok=0 local existing="${!__var:-}" if [ -n "$existing" ]; then printf -v "$__var" '%s' "$existing"; say "${prompt}:${existing}(来自环境变量)"; return; fi if [ "$NON_INTERACTIVE" -eq 1 ]; then printf -v "$__var" '%s' "$default"; say "${prompt}:${default}(默认,非交互)"; return fi # 子 shell 里试开 /dev/tty:能开才交互(-r 只测权限位,Windows/无 TTY 会漏判) if (exec 3/dev/null; then if [ -n "$default" ]; then printf "%s [%s]: " "$prompt" "$default" > /dev/tty else printf "%s: " "$prompt" > /dev/tty; fi if IFS= read -r reply < /dev/tty; then read_ok=1; fi fi if [ "$read_ok" -eq 0 ]; then warn "${prompt}:无可用 TTY,使用默认值 ${default}"; fi printf -v "$__var" '%s' "${reply:-$default}" } PANEL_HOSTNAME="${PANEL_HOSTNAME:-}"; ask PANEL_HOSTNAME '面板域名(如 clover.example.com)' '' PANEL_HOSTNAME="${PANEL_HOSTNAME,,}" MAIL_DOMAIN="${MAIL_DOMAIN:-${PANEL_HOSTNAME#clover.}}" MAIL_HOSTNAME="${MAIL_HOSTNAME:-mail.${MAIL_DOMAIN}}" ACME_EMAIL="${ACME_EMAIL:-admin@${MAIL_DOMAIN}}" TZ="${TZ:-}"; ask TZ '时区' "$(timedatectl show -p Timezone --value 2>/dev/null || echo 'Asia/Shanghai')" SHARED_PROXY=0 if [ "$SHARED_NGINX" -eq 1 ] || [ "$SHARED_CADDY" -eq 1 ]; then SHARED_PROXY=1; fi CLOVER_HTTP_PORT="${CLOVER_HTTP_PORT:-$([ "$SHARED_PROXY" -eq 1 ] && echo 18080 || echo 80)}" CLOVER_HTTPS_PORT="${CLOVER_HTTPS_PORT:-$([ "$SHARED_PROXY" -eq 1 ] && echo 18443 || echo 443)}" CLOVER_CADDY_HTTP_PUBLISH="${CLOVER_CADDY_HTTP_PUBLISH:-$([ "$SHARED_PROXY" -eq 1 ] && echo 127.0.0.1:${CLOVER_HTTP_PORT} || echo "$CLOVER_HTTP_PORT")}" CLOVER_CADDY_HTTPS_PUBLISH="${CLOVER_CADDY_HTTPS_PUBLISH:-$([ "$SHARED_PROXY" -eq 1 ] && echo 127.0.0.1:${CLOVER_HTTPS_PORT} || echo "$CLOVER_HTTPS_PORT")}" CLOVER_CADDY_SCHEME="${CLOVER_CADDY_SCHEME:-$([ "$SHARED_PROXY" -eq 1 ] && echo http || echo https)}" CLOVER_SHARED_NGINX="$SHARED_NGINX" CLOVER_SHARED_CADDY="$SHARED_CADDY" if [ "$SHARED_PROXY" -eq 1 ]; then CLOVER_TLS_DIRECTIVE=""; fi CLOVER_TLS_DIRECTIVE="${CLOVER_TLS_DIRECTIVE:-}" CLOVER_CF_API_TOKEN="${CLOVER_CF_API_TOKEN:-}" # 低配机器(< 2GB)自动关 ClamAV:吃内存,1GB 机必关 : "${ENABLE_CLAMAV:=1}" if [ "${MEM_MB:-0}" -lt 2000 ] && [ "$ENABLE_CLAMAV" = "1" ]; then warn "内存 ${MEM_MB}MB < 2GB → 自动关闭 ClamAV(.env 里 ENABLE_CLAMAV=0),需要可手工改回" ENABLE_CLAMAV=0 fi # 80/443 被占用时提示改端口(共享主机常见) for p in "$CLOVER_HTTP_PORT" "$CLOVER_HTTPS_PORT"; do if port_in_use "$p"; then warn "宿主端口 ${p} 已被占用——请设 CLOVER_HTTP_PORT/CLOVER_HTTPS_PORT 换个端口,否则 Caddy 起不来" fi done # 校验 printf '%s' "$MAIL_DOMAIN" | grep -Eq '^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$' \ || die "邮件主域名格式不正确:${MAIL_DOMAIN}" printf '%s' "$PANEL_HOSTNAME" | grep -Eq '^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' || die "面板域名格式不正确:${PANEL_HOSTNAME}" printf '%s' "$ACME_EMAIL" | grep -Eq '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$' || die "邮箱格式不正确:${ACME_EMAIL}" [ "$MAIL_DOMAIN" != "$PANEL_HOSTNAME" ] || warn "面板域名与邮件主域名相同:可运行,但建议分开(面板子域名更安全)" # --------------------------------------------------------------------------- # 3. 生成 .env # --------------------------------------------------------------------------- step "3/6 生成配置(.env)" gen_secret() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 else head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'; fi } if [ -f .env ]; then BAK=".env.bak.$(date +%Y%m%d%H%M%S)" warn ".env 已存在 → 备份为 ${BAK}(重新配置会覆盖)" [ "$DRY_RUN" -eq 1 ] || cp .env "$BAK" SESSION_SECRET="$(grep -E '^SESSION_SECRET=' .env | cut -d= -f2- || true)" RECONCILE_TOKEN="$(grep -E '^RECONCILE_TOKEN=' .env | cut -d= -f2- || true)" fi : "${SESSION_SECRET:=}" : "${RECONCILE_TOKEN:=}" [ -z "$SESSION_SECRET" ] || [ "${#SESSION_SECRET}" -lt 16 ] && SESSION_SECRET="$(gen_secret)" [ -z "$RECONCILE_TOKEN" ] && RECONCILE_TOKEN="$(gen_secret)" if [ "$DRY_RUN" -eq 1 ]; then printf "${DIM} [dry-run] cp .env.example .env && 填入 %s 等 %s 项 + 随机密钥${NC}\n" "$MAIL_DOMAIN" "9" else cp .env.example .env set_env() { sed -i "s|^${1}=.*|${1}=${2}|" .env; } set_env MAIL_DOMAIN "$MAIL_DOMAIN" set_env MAIL_HOSTNAME "$MAIL_HOSTNAME" set_env PANEL_HOSTNAME "$PANEL_HOSTNAME" set_env ACME_EMAIL "$ACME_EMAIL" set_env TZ "$TZ" set_env SESSION_SECRET "$SESSION_SECRET" set_env RECONCILE_TOKEN "$RECONCILE_TOKEN" set_env CLOVER_VPS_IP "${VPS_IP:-}" set_env CLOVER_CF_API_TOKEN "${CLOVER_CF_API_TOKEN:-}" set_env CLOVER_HTTP_PORT "${CLOVER_HTTP_PORT:-80}" set_env CLOVER_HTTPS_PORT "${CLOVER_HTTPS_PORT:-443}" set_env CLOVER_CADDY_HTTP_PUBLISH "$CLOVER_CADDY_HTTP_PUBLISH" set_env CLOVER_CADDY_HTTPS_PUBLISH "$CLOVER_CADDY_HTTPS_PUBLISH" set_env CLOVER_CADDY_SCHEME "$CLOVER_CADDY_SCHEME" set_env CLOVER_SHARED_NGINX "$CLOVER_SHARED_NGINX" set_env CLOVER_SHARED_CADDY "$CLOVER_SHARED_CADDY" set_env CLOVER_HOST_CADDY_CONFIG "$HOST_CADDY_CONFIG" set_env CLOVER_HOST_CADDY_BIN "$HOST_CADDY_BIN" set_env CLOVER_HOME "$CLOVER_HOME" set_env CLOVER_TLS_DIRECTIVE "${CLOVER_TLS_DIRECTIVE:-}" if [ "${ENABLE_CLAMAV:-1}" = "0" ]; then set_env ENABLE_CLAMAV 0; fi chmod 600 .env say ".env 已生成(含随机 SESSION_SECRET / RECONCILE_TOKEN,权限 600)" fi if [ "$DRY_RUN" -eq 0 ] && [ -x scripts/install-cli.sh ]; then bash scripts/install-cli.sh "$CLOVER_HOME" || warn "快捷命令未能安装;可运行 bash ${CLOVER_HOME}/scripts/install-cli.sh 手动安装" fi # 正式安装始终使用已发布的 GHCR 镜像。bootstrap 解压的仓库源码也可能含有 panel/, # 不能据此推断用户希望在 VPS 本地构建。 COMPOSE_FILES=(-f compose.yaml) say "使用已发布的 GHCR 面板镜像:${CLOVER_PANEL_IMAGE:-ghcr.io/vincentkeio/clover-panel}:${CLOVER_TAG:-latest}" # --------------------------------------------------------------------------- # 4. 启动 # --------------------------------------------------------------------------- step "4/6 拉取镜像并启动(首次约 3–10 分钟,ClamAV 镜像较大)" # 拉取失败就停止,避免拿旧缓存启动,或因为源码目录存在而转成本地构建。 run bash scripts/ensure-roundcube-env.sh run docker compose "${COMPOSE_FILES[@]}" pull panel mail caddy roundcube \ || die "无法拉取发布镜像;请检查网络与 GHCR 访问后重试。未启动旧缓存镜像。" run docker compose "${COMPOSE_FILES[@]}" build certificate-helper run docker compose "${COMPOSE_FILES[@]}" up -d --no-build if [ "$SHARED_CADDY" -eq 1 ]; then step "4.1/6 配置宿主机 Caddy 入口" if [ "$DRY_RUN" -eq 1 ]; then printf "${DIM} [dry-run] python3 scripts/host-caddy.py configure-env --env-file .env${NC}\n" else python3 scripts/host-caddy.py configure-env --env-file .env \ || die "宿主机 Caddy 配置失败;原配置已回滚。修复后可运行 python3 scripts/host-caddy.py configure-env --env-file .env 重试。" fi fi if [ "$SHARED_NGINX" -eq 1 ]; then step "4.1/6 配置 nginx 与面板证书" if [ "$DRY_RUN" -eq 1 ]; then printf "${DIM} [dry-run] docker exec clover-certificate-helper python3 /app/certificates.py issue --force${NC}\n" else for i in $(seq 1 30); do docker exec clover-certificate-helper python3 -c 'print("ready")' >/dev/null 2>&1 && break; sleep 1; done docker exec clover-certificate-helper python3 /app/certificates.py issue --force \ || die "面板证书签发失败。nginx 已尝试恢复;查看 docker logs clover-certificate-helper,并确认面板域名 A 记录直连本机、Cloudflare 代理关闭且公网 80 可达后重试。" fi fi # --------------------------------------------------------------------------- # 4.5 DMS TLS 证书 —— 587/465 认证发信的前提 # 实机验证:DMS 未配证书时 submission 端口不提供 STARTTLS,客户端无法登录发信。 # 这里自动生成自签证书(够用:客户端设「接受自签」即可);生产建议换真证书。 # --------------------------------------------------------------------------- if [ "$DRY_RUN" -eq 1 ]; then printf "${DIM} [dry-run] 为 DMS 生成 TLS 自签证书(CN=${MAIL_HOSTNAME})并写 SSL_TYPE=manual${NC}\n" else step "4.5/6 DMS TLS 证书(认证发信必需)" if grep -qE '^SSL_TYPE=..*' .env 2>/dev/null; then say ".env 已有 SSL_TYPE,跳过自签证书(生产环境请用真证书)" else for i in $(seq 1 30); do docker exec docker-mailserver sh -c 'true' >/dev/null 2>&1 && break; sleep 3; done if command -v openssl >/dev/null 2>&1; then docker exec docker-mailserver sh -c 'mkdir -p /tmp/docker-mailserver/ssl' openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj "/CN=${MAIL_HOSTNAME}" \ -keyout /tmp/clover-mail.key -out /tmp/clover-mail.crt 2>/dev/null docker cp /tmp/clover-mail.crt docker-mailserver:/tmp/docker-mailserver/ssl/mail.crt docker cp /tmp/clover-mail.key docker-mailserver:/tmp/docker-mailserver/ssl/mail.key docker exec docker-mailserver chmod 600 /tmp/docker-mailserver/ssl/mail.key printf 'SSL_TYPE=manual\nSSL_CERT_PATH=/tmp/docker-mailserver/ssl/mail.crt\nSSL_KEY_PATH=/tmp/docker-mailserver/ssl/mail.key\n' >> .env run docker compose "${COMPOSE_FILES[@]}" up -d mail rm -f /tmp/clover-mail.key /tmp/clover-mail.crt warn "已启用自签 TLS。生产环境请换真证书(见 README「邮件 TLS 证书」),否则部分客户端会告警" else warn "缺少 openssl,跳过 DMS TLS 证书生成 —— 587/465 认证发信会失败!" warn "手工补:给 DMS 配 SSL_TYPE=manual + 证书路径(见 README)" fi fi fi # --------------------------------------------------------------------------- # 5. 健康检查(容器内 fetch,不依赖 DNS/证书) # --------------------------------------------------------------------------- step "5/6 健康检查" if [ "$DRY_RUN" -eq 1 ]; then printf "${DIM} [dry-run] 轮询容器内 /api/health(最多 90s)${NC}\n" else ok=0 for i in $(seq 1 45); do if docker exec clover-panel node -e \ "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" \ >/dev/null 2>&1; then ok=1; break; fi sleep 2 done if [ "$ok" -eq 1 ]; then say "面板健康 ✔" docker exec clover-panel node -e \ "fetch('http://127.0.0.1:3000/api/health').then(r=>r.text()).then(t=>console.log(' '+t))" || true bash scripts/admin-bootstrap.sh bash scripts/configure-roundcube.sh || die "Roundcube 免密初始化失败,请检查邮件服务后运行 bash scripts/configure-roundcube.sh。" else warn "面板未在 90s 内就绪,查看日志:docker logs --tail 100 clover-panel" fi echo docker compose "${COMPOSE_FILES[@]}" ps || true fi # --------------------------------------------------------------------------- # 6. 下一步指引 # --------------------------------------------------------------------------- step "6/6 接下来" if [ "$DRY_RUN" -eq 1 ]; then say "dry-run 结束:以上为将要执行的全部动作。去掉 --dry-run 即真正安装。" exit 0 fi cat <