#!/usr/bin/env sh set -eu # 首次部署用: # 1. 校验每个域名都有 nginx 配置,且配置里放行 ACME HTTP-01 校验路径。 # 2. 为缺失的证书路径生成 1 天有效期的自签 dummy 证书,避免 OpenResty 因证书文件不存在而启动失败。 # 3. 启动 OpenResty,让 certbot 的 webroot 校验可以访问 /.well-known/acme-challenge/。 # 4. 删除本次创建的 dummy 证书文件,避免挡住 certbot 创建正式证书目录。 # 5. 运行 Certbot,为每个域名单独签发正式证书。 # 6. 重启 OpenResty,让正式证书生效。 # 7. 按需安装证书自动续期 cron 任务,后续证书会定期检查续期。 # nginx 容器里挂载到 /etc/letsencrypt,宿主机这里对应 ./certs。 # nginx 配置引用的是 /etc/letsencrypt/live/域名/fullchain.pem。 CERT_ROOT="./certs/live" # 无论用户从哪个目录执行脚本,都先切到项目根目录,保证相对路径稳定。 ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" CONF_ROOT="${CONF_ROOT:-./conf}" CONF_DIR="${CONF_DIR:-./conf/conf.d}" CREATE_MISSING_CONF="${CREATE_MISSING_CONF:-0}" INSTALL_RENEW_CRON="${INSTALL_RENEW_CRON:-1}" # 记录本次脚本创建或复用的 dummy 证书域名。 # 后面只删除这些 dummy 证书,不动已经存在的正式证书。 DUMMY_DOMAINS="" cd "$ROOT_DIR" usage() { cat <&2 usage >&2 exit 1 ;; esac shift done if [ -z "${DOMAINS:-}" ]; then echo "错误:必须设置 DOMAINS。请先在 init-certs.sh 中配置,再调用 init-certs-core.sh。" >&2 exit 1 fi if [ -z "${CERT_EMAIL:-}" ]; then echo "错误:必须设置 CERT_EMAIL。请先在 init-certs.sh 中配置,再调用 init-certs-core.sh。" >&2 exit 1 fi # 阶段 1:校验每个域名都有 nginx 配置和 ACME 校验路径。 conf_error=0 for domain in $DOMAINS; do if ! conf_file="$(find_domain_conf "$domain")"; then if is_enabled "$CREATE_MISSING_CONF"; then CONF_DIR="$CONF_DIR" sh "$ROOT_DIR/scripts/ensure-domain-conf.sh" "$domain" conf_file="$CONF_DIR/$domain.conf" else echo "错误:缺少 nginx 配置:$CONF_DIR/$domain.conf" >&2 echo "提示:确认配置文件存在,或执行 sh init-certs.sh --create-missing-conf 自动生成基础模板。" >&2 conf_error=1 continue fi fi if ! grep -Fq "/.well-known/acme-challenge/" "$conf_file"; then echo "错误:nginx 配置缺少 ACME 校验路径:$conf_file" >&2 echo "提示:需要添加 location ^~ /.well-known/acme-challenge/ 并把 root 指向 /var/www。" >&2 conf_error=1 fi done # 反向检查 nginx 配置里引用的证书域名是否都被 DOMAINS 覆盖。 # 否则 OpenResty 可能因为某个额外 HTTPS 站点缺证书而启动失败。 if [ -d "$CONF_ROOT" ]; then ssl_domains="$( find "$CONF_ROOT" -type f -name '*.conf' | while IFS= read -r conf_file; do [ -f "$conf_file" ] || continue sed -n 's#^[[:space:]]*ssl_certificate[[:space:]][[:space:]]*/etc/letsencrypt/live/\([^/][^/]*\)/fullchain\.pem;.*#\1#p' "$conf_file" done | sort -u )" else echo "错误:nginx 配置扫描目录不存在:$CONF_ROOT" >&2 ssl_domains="" conf_error=1 fi for domain in $ssl_domains; do if ! domain_in_list "$domain"; then echo "错误:nginx 配置引用了未加入 DOMAINS 的证书域名:$domain" >&2 echo "提示:请把 $domain 加到 init-certs.sh 的 DOMAINS,或移除对应 ssl_certificate 配置。" >&2 conf_error=1 fi done if [ "$conf_error" -ne 0 ]; then exit 1 fi # 阶段 2:选择可用的 Docker Compose 命令。 . "$ROOT_DIR/scripts/lib-compose.sh" # 阶段 3:为缺失证书的域名生成 dummy 证书。 # 目的不是让浏览器信任,而是让 OpenResty 启动时能找到证书文件。 export DOMAINS CERT_ROOT DUMMY_DOMAINS="$(sh "$ROOT_DIR/scripts/ensure-dummy-certs.sh")" # 阶段 4:启动 OpenResty。 # 这里能启动,是因为上面已经确保每个 HTTPS 域名都有证书文件。 # 启动后,Let's Encrypt 才能通过 HTTP 访问 /.well-known/acme-challenge/ 校验文件。 echo "使用 dummy 证书启动 OpenResty..." compose up -d openresty if [ -n "$DUMMY_DOMAINS" ]; then # 阶段 5:删除本次创建或复用的 dummy 证书。 # 必须先删掉 dummy 文件,否则 certbot 可能认为 live/域名 目录已经被占用。 # 已存在的正式证书不会进入 DUMMY_DOMAINS,所以不会被删除。 echo "申请正式证书前删除 dummy 证书文件..." for domain in $DUMMY_DOMAINS; do cert_dir="$CERT_ROOT/$domain" rm -f "$cert_dir/fullchain.pem" "$cert_dir/privkey.pem" "$cert_dir/.dummy-init-certs" rmdir "$cert_dir" 2>/dev/null || true done fi # 阶段 6:申请正式 Let's Encrypt 证书。 # certbot 会把 challenge 文件写到 ./www,然后由 OpenResty 对外提供访问。 if ! sh "$ROOT_DIR/scripts/request-certs.sh"; then echo "证书申请失败,恢复缺失域名的 dummy 证书,避免 OpenResty 后续因证书文件缺失而无法启动..." >&2 if ! sh "$ROOT_DIR/scripts/ensure-dummy-certs.sh" >/dev/null; then echo "警告:dummy 证书恢复失败,请手动检查 $CERT_ROOT。" >&2 fi exit 1 fi # 阶段 7:重启 OpenResty。 # OpenResty 启动时已经加载过 dummy 证书,正式证书签发后需要重启才能加载新文件。 echo "重启 OpenResty 以加载正式证书..." compose restart openresty # 阶段 8:安装证书自动续期任务。 # scripts/install-renew-cron.sh 是幂等的:重复执行会先删除旧任务块,再写入新任务。 # 这样首次部署只需要执行 init-certs.sh,就能同时完成启动和自动续期安装。 if is_enabled "$INSTALL_RENEW_CRON"; then if command -v crontab >/dev/null 2>&1; then echo "安装证书自动续期 cron 任务..." sh "$ROOT_DIR/scripts/install-renew-cron.sh" else echo "警告:缺少 crontab 命令,已跳过证书自动续期任务安装。" >&2 echo "提示:安装 crontab 后可手动执行 sh scripts/install-renew-cron.sh。" >&2 fi else echo "跳过证书自动续期 cron 安装。" fi echo "完成。"