#!/bin/bash # ============================================ # SSL 证书初始化脚本(首次部署时运行) # # 使用 Let's Encrypt (certbot) 为 3 个域名申请证书 # 流程: # 1. 启动 Nginx(仅 HTTP 80 端口,用于 ACME 验证) # 2. 用 certbot 对每个域名申请证书 # 3. 重新加载 Nginx 以启用 HTTPS # # 用法: # ./init-ssl.sh # 正式申请证书 # ./init-ssl.sh --staging # 使用 staging 环境测试(不受速率限制) # ./init-ssl.sh --dry-run # 仅测试,不真正申请 # ============================================ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" # 颜色输出 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } # 加载 .env if [ ! -f .env ]; then log_error ".env 文件不存在!请先执行: cp .env.example .env 并填写配置" exit 1 fi source .env # 检查必要变量 if [ -z "$SSL_EMAIL" ]; then log_error "请在 .env 中设置 SSL_EMAIL(用于 Let's Encrypt 证书申请通知)" exit 1 fi # 从 ROOT_DOMAIN 自动推导子域名(如 .env 中未单独配置) if [ -z "$ROOT_DOMAIN" ]; then log_error "请在 .env 中设置 ROOT_DOMAIN(父域名,如 example.com)" exit 1 fi : ${API_DOMAIN:="api.${ROOT_DOMAIN}"} : ${DLWEB_DOMAIN:="dlapi.${ROOT_DOMAIN}"} : ${WX_DOMAIN:="wxapi.${ROOT_DOMAIN}"} DOMAINS=("$API_DOMAIN" "$DLWEB_DOMAIN" "$WX_DOMAIN") # 判断使用 docker-compose 还是 docker compose COMPOSE_CMD="docker compose" if ! docker compose version &> /dev/null 2>&1; then COMPOSE_CMD="docker-compose" fi # 获取 Docker Compose 项目名(用于 volume 前缀) PROJECT_NAME="$($COMPOSE_CMD ps --format '{{.Project}}' 2>/dev/null | head -1)" if [ -z "$PROJECT_NAME" ]; then PROJECT_NAME="$(basename "$SCRIPT_DIR")" fi # 解析参数 STAGING_ARG="" DRY_RUN="" for arg in "$@"; do case $arg in --staging) STAGING_ARG="--staging" log_warn "使用 Let's Encrypt Staging 环境(测试用,证书不受信任)" ;; --dry-run) DRY_RUN="--dry-run" log_warn "Dry-run 模式,不会真正申请证书" ;; esac done # ============================================ # Step 1: 生成临时自签名证书(让 Nginx 能先启动) # ============================================ log_info "Step 1: 生成临时自签名证书..." for domain in "${DOMAINS[@]}"; do CERT_DIR="./docker/nginx/dummy-certs/$domain" mkdir -p "$CERT_DIR" if [ ! -f "$CERT_DIR/fullchain.pem" ]; then openssl req -x509 -nodes -newkey rsa:2048 -days 1 \ -keyout "$CERT_DIR/privkey.pem" \ -out "$CERT_DIR/fullchain.pem" \ -subj "/CN=$domain" 2>/dev/null log_info " 已生成临时自签名证书: $domain" fi done # ============================================ # Step 2: 将临时证书写入 certbot-certs volume # ============================================ log_info "Step 2: 初始化证书 volume..." # 先构建镜像并创建 volume(不启动 nginx,因为证书还没写入) $COMPOSE_CMD build nginx 2>/dev/null || true $COMPOSE_CMD up --no-start nginx 2>/dev/null || true # 用临时 alpine 容器直接挂载 certbot-certs volume(读写)写入证书 # nginx 挂载该卷为 :ro,不能通过 docker cp 写入,需绕过 DUMMY_CERTS_ABS="$(cd "$(dirname "$0")" && pwd)/docker/nginx/dummy-certs" for domain in "${DOMAINS[@]}"; do LIVE_DIR="/etc/letsencrypt/live/$domain" log_info " 写入临时证书: $domain" docker run --rm \ -v "${PROJECT_NAME}_certbot-certs:/etc/letsencrypt" \ -v "$DUMMY_CERTS_ABS/$domain:/src:ro" \ alpine sh -c "mkdir -p '$LIVE_DIR' && cp /src/fullchain.pem '$LIVE_DIR/' && cp /src/privkey.pem '$LIVE_DIR/'" done # 启动 nginx(证书已就绪) $COMPOSE_CMD up -d nginx 2>/dev/null || true sleep 2 # 重新加载 Nginx 以确认证书加载 docker exec youle-nginx nginx -s reload 2>/dev/null || true log_info " Nginx 已使用临时证书启动" # ============================================ # Step 3: 用 certbot 申请真实证书 # ============================================ log_info "Step 3: 申请 Let's Encrypt 证书..." # 清除 volume 中的 dummy 证书目录,避免 certbot 报 "live directory exists" log_info " 清理 volume 中的临时证书目录..." CLEAN_CMD="rm -rf" for domain in "${DOMAINS[@]}"; do CLEAN_CMD="$CLEAN_CMD /etc/letsencrypt/live/$domain /etc/letsencrypt/live/${domain}-* /etc/letsencrypt/archive/$domain /etc/letsencrypt/renewal/$domain.conf" done docker run --rm \ -v "${PROJECT_NAME}_certbot-certs:/etc/letsencrypt" \ alpine sh -c "$CLEAN_CMD" 2>/dev/null || true for domain in "${DOMAINS[@]}"; do log_info " 正在为 $domain 申请证书..." docker run --rm \ -v "${PROJECT_NAME}_certbot-webroot:/var/www/certbot" \ -v "${PROJECT_NAME}_certbot-certs:/etc/letsencrypt" \ --entrypoint certbot \ certbot/certbot:latest \ certonly \ --webroot \ -w /var/www/certbot \ --email "$SSL_EMAIL" \ --agree-tos \ --no-eff-email \ --force-renewal \ -d "$domain" \ $STAGING_ARG \ $DRY_RUN if [ $? -eq 0 ]; then log_info " ✓ $domain 证书申请成功" else log_error " ✗ $domain 证书申请失败!请检查:" log_error " - 域名 DNS 是否已正确解析到本服务器" log_error " - 服务器 80 端口是否对外开放" log_error " - 是否超过 Let's Encrypt 速率限制(可用 --staging 测试)" fi done # ============================================ # Step 4: 重新加载 Nginx 以使用真实证书 # ============================================ log_info "Step 4: 重新加载 Nginx..." docker exec youle-nginx nginx -s reload # ============================================ # Step 5: 清理临时文件并启动 certbot 定时续签 # ============================================ rm -rf ./docker/nginx/dummy-certs log_info "Step 5: 启动 certbot 自动续签服务..." $COMPOSE_CMD up -d certbot # ============================================ # Step 6: 安装 crontab 定时重载 Nginx(使续签生效) # ============================================ log_info "Step 6: 设置自动重载 Nginx 的 crontab..." CRON_JOB="0 */12 * * * docker exec youle-nginx nginx -s reload >/dev/null 2>&1" CRON_MARKER="# youle-nginx-ssl-reload" # 检查是否已存在 if crontab -l 2>/dev/null | grep -q "$CRON_MARKER"; then log_info " crontab 已存在,跳过" else # 追加到当前用户的 crontab (crontab -l 2>/dev/null; echo "$CRON_JOB $CRON_MARKER") | crontab - log_info " 已添加 crontab: 每 12 小时重载 Nginx(使续签的证书生效)" fi echo "" log_info "============================================" log_info "SSL 初始化完成!" log_info "============================================" echo "" echo " 证书信息:" for domain in "${DOMAINS[@]}"; do echo " https://$domain" done echo "" echo " 证书有效期: 90 天" echo " 自动续签: certbot 容器每 12 小时检查一次" echo " 自动重载: crontab 每 12 小时执行 nginx -s reload" echo "" echo " 查看证书状态:" echo " $COMPOSE_CMD run --rm certbot certificates" echo "" echo " 手动续签:" echo " ./deploy.sh ssl-renew" echo ""