This commit is contained in:
2026-05-18 22:32:08 +08:00
parent 6ab44ea187
commit bd4caa0f09
22 changed files with 1178 additions and 297 deletions

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env sh
set -eu
# 为传入的域名生成基础 nginx 配置。
# 如果 conf/conf.d/xxx.conf 已经存在,直接跳过,避免覆盖人工维护的配置。
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
# 默认写入 openresty-gateway/conf/conf.d。
# 如需生成到其他目录,可以在执行前设置 CONF_DIR例如
# CONF_DIR="./conf/test.d" sh scripts/ensure-domain-conf.sh example.com
CONF_DIR="${CONF_DIR:-./conf/conf.d}"
cd "$ROOT_DIR"
# 必须显式传入域名,避免无参数执行时静默成功但什么都不做。
if [ "$#" -eq 0 ]; then
echo "用法sh scripts/ensure-domain-conf.sh <域名> [域名...]" >&2
exit 1
fi
ensure_domain_conf() {
domain="$1"
conf_file="$CONF_DIR/$domain.conf"
# 已存在的域名配置不重新生成,避免覆盖已有转发、限流、路径规则等自定义配置。
if [ -f "$conf_file" ]; then
echo "跳过已存在的 nginx 配置:$conf_file"
return
fi
# 这里只生成一个能响应 ACME webroot 校验的最小 HTTPS 站点模板。
# 业务代理规则可以后续在生成的 conf 文件里按需补充。
echo "创建 nginx 配置模板:$conf_file"
mkdir -p "$CONF_DIR"
cat > "$conf_file" <<EOF
server {
listen 80;
listen 443 ssl;
server_name $domain;
ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem;
root /var/www/$domain;
index index.html;
location ^~ /.well-known/acme-challenge/ {
root /var/www;
default_type text/plain;
try_files \$uri =404;
}
location / {
if (\$scheme = http) {
return 301 https://\$host\$request_uri;
}
try_files \$uri \$uri/ /index.html;
}
}
EOF
}
for domain in "$@"; do
ensure_domain_conf "$domain"
done

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env sh
set -eu
# 为缺失证书的域名生成临时 dummy 证书。
# 已有完整正式证书时直接跳过,不覆盖线上可用证书。
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
CERT_ROOT="${CERT_ROOT:-./certs/live}"
cd "$ROOT_DIR"
if [ -z "${DOMAINS:-}" ]; then
echo "错误:必须设置 DOMAINS。" >&2
exit 1
fi
if ! command -v openssl >/dev/null 2>&1; then
echo "错误:缺少 openssl 命令。" >&2
exit 1
fi
for domain in $DOMAINS; do
cert_dir="$CERT_ROOT/$domain"
cert_file="$cert_dir/fullchain.pem"
key_file="$cert_dir/privkey.pem"
marker_file="$cert_dir/.dummy-init-certs"
tmp_cert_file="$cert_file.tmp"
tmp_key_file="$key_file.tmp"
mkdir -p "$cert_dir"
if [ -f "$cert_file" ] && [ -f "$key_file" ]; then
if [ -f "$marker_file" ]; then
# 上一次脚本可能中途失败,留下了 dummy 证书。
# 继续把它当作 dummy 处理,后面会删除并重新申请正式证书。
echo "复用已存在的 dummy 证书:$domain" >&2
echo "$domain"
continue
fi
# 已经有正式证书时不覆盖,避免误删线上可用证书。
echo "跳过已存在的正式证书:$domain" >&2
continue
fi
if [ -f "$cert_file" ] || [ -f "$key_file" ]; then
if [ -f "$marker_file" ]; then
# 上次生成 dummy 证书时可能中途退出,留下了不完整文件。
# 这些文件由本脚本创建,可以安全清理后重建。
echo "清理不完整的 dummy 证书:$domain" >&2
rm -f "$cert_file" "$key_file" "$marker_file" "$tmp_cert_file" "$tmp_key_file"
else
# 只存在证书或只存在私钥,状态不完整。
# 自动处理可能误删用户文件,所以直接停止,让用户手工确认。
echo "错误:$domain 存在不完整的证书文件,请手动检查目录:$cert_dir" >&2
exit 1
fi
fi
if [ ! -f "$cert_file" ] && [ ! -f "$key_file" ]; then
# marker 先创建,避免 openssl 成功后脚本中断时留下无 marker 的 dummy 文件。
rm -f "$tmp_cert_file" "$tmp_key_file"
: > "$marker_file"
echo "创建 dummy 证书:$domain" >&2
if ! openssl req -x509 -nodes -newkey rsa:2048 -days 1 \
-keyout "$tmp_key_file" \
-out "$tmp_cert_file" \
-subj "/CN=$domain"; then
rm -f "$tmp_cert_file" "$tmp_key_file" "$marker_file"
exit 1
fi
mv -f "$tmp_key_file" "$key_file"
mv -f "$tmp_cert_file" "$cert_file"
echo "$domain"
fi
done

246
scripts/init-certs-core.sh Normal file
View File

@@ -0,0 +1,246 @@
#!/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 <<EOF
用法sh init-certs.sh [选项]
选项:
--create-missing-conf 为缺少 nginx 配置的域名生成基础模板
--skip-renew-cron 跳过证书自动续期 cron 安装
-h, --help 显示帮助
环境变量:
CREATE_MISSING_CONF=1 等同于 --create-missing-conf
INSTALL_RENEW_CRON=0 等同于 --skip-renew-cron
CONF_ROOT=./conf 指定 nginx 配置扫描根目录
CONF_DIR=./conf/conf.d 指定 nginx 域名配置目录
EOF
}
is_enabled() {
case "${1:-}" in
1|true|TRUE|yes|YES|on|ON)
return 0
;;
*)
return 1
;;
esac
}
domain_in_list() {
needle="$1"
for item in $DOMAINS; do
if [ "$item" = "$needle" ]; then
return 0
fi
done
return 1
}
domain_conf_matches() {
domain="$1"
conf_file="$2"
awk -v domain="$domain" '
/^[[:space:]]*server_name[[:space:]]/ {
for (i = 2; i <= NF; i++) {
name = $i
sub(/;$/, "", name)
if (name == domain) {
found = 1
}
}
}
END {
exit found ? 0 : 1
}
' "$conf_file"
}
find_domain_conf() {
domain="$1"
exact_conf_file="$CONF_DIR/$domain.conf"
if [ -f "$exact_conf_file" ]; then
echo "$exact_conf_file"
return 0
fi
for conf_file in "$CONF_DIR"/*.conf; do
[ -f "$conf_file" ] || continue
if domain_conf_matches "$domain" "$conf_file"; then
echo "$conf_file"
return 0
fi
done
return 1
}
while [ "$#" -gt 0 ]; do
case "$1" in
--create-missing-conf)
CREATE_MISSING_CONF=1
;;
--skip-renew-cron)
INSTALL_RENEW_CRON=0
;;
-h|--help)
usage
exit 0
;;
*)
echo "错误:未知参数:$1" >&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 "完成。"

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env sh
set -eu
# 安装证书自动续期定时任务。
# 默认每天 03:00 执行 scripts/renew-certs.sh并把日志写到 logs/cert-renew.log。
# 可以重复执行:安装前会先删除旧的 openresty-gateway cert renew 任务块,再写入新的任务。
MARK_BEGIN="# BEGIN openresty-gateway cert renew"
MARK_END="# END openresty-gateway cert renew"
SCHEDULE="${SCHEDULE:-0 3 * * *}"
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
LOG_DIR="$ROOT_DIR/logs"
CRON_LINE="$SCHEDULE cd '$ROOT_DIR' && sh scripts/renew-certs.sh >> logs/cert-renew.log 2>&1"
if ! command -v crontab >/dev/null 2>&1; then
echo "错误:缺少 crontab 命令。" >&2
exit 1
fi
mkdir -p "$LOG_DIR"
tmp_file="$(mktemp)"
trap 'rm -f "$tmp_file"' EXIT
echo "删除已存在的证书续期 cron 块(如果存在)..."
crontab -l 2>/dev/null | sed "/$MARK_BEGIN/,/$MARK_END/d" > "$tmp_file"
{
echo "$MARK_BEGIN"
echo "$CRON_LINE"
echo "$MARK_END"
} >> "$tmp_file"
crontab "$tmp_file"
echo "已安装证书续期 cron"
echo "$CRON_LINE"

16
scripts/lib-compose.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env sh
# 定义 compose(),统一兼容新版 `docker compose` 和旧版 `docker-compose`。
if docker compose version >/dev/null 2>&1; then
compose() {
docker compose "$@"
}
elif command -v docker-compose >/dev/null 2>&1; then
compose() {
docker-compose "$@"
}
else
echo "错误:缺少 docker compose 或 docker-compose 命令。" >&2
exit 1
fi

26
scripts/renew-certs.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env sh
set -eu
# 日常续期用:
# certbot renew 会自己判断证书是否快过期;没到续期时间不会真正重签。
# renew 成功或无须续期后,重载 OpenResty让已经更新的证书生效。
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$ROOT_DIR"
. "$ROOT_DIR/scripts/lib-compose.sh"
echo "按需续期证书..."
compose run --rm --entrypoint certbot certbot \
renew --webroot -w /var/www
echo "重载 OpenResty 以使用续期后的证书..."
if compose exec -T openresty openresty -s reload; then
echo "OpenResty 已重载。"
else
echo "重载失败,改为重启 OpenResty..."
compose restart openresty
fi
echo "完成。"

31
scripts/request-certs.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env sh
set -eu
# Request Let's Encrypt certificates for DOMAINS.
# Existing valid certificates are kept by certbot because of --keep-until-expiring.
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$ROOT_DIR"
if [ -z "${DOMAINS:-}" ]; then
echo "错误:必须设置 DOMAINS。" >&2
exit 1
fi
if [ -z "${CERT_EMAIL:-}" ]; then
echo "错误:必须设置 CERT_EMAIL。" >&2
exit 1
fi
. "$ROOT_DIR/scripts/lib-compose.sh"
echo "使用 certbot 申请正式证书..."
for domain in $DOMAINS; do
echo "申请正式证书:$domain"
compose run --rm --entrypoint certbot certbot \
certonly --webroot -w /var/www -d "$domain" \
--email "$CERT_EMAIL" --agree-tos --non-interactive --keep-until-expiring
done
echo "证书申请步骤完成。"

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
# 卸载 scripts/install-renew-cron.sh 安装的证书自动续期定时任务。
# 只删除固定标记之间的内容,不影响其它 crontab 任务。
MARK_BEGIN="# BEGIN openresty-gateway cert renew"
MARK_END="# END openresty-gateway cert renew"
if ! command -v crontab >/dev/null 2>&1; then
echo "错误:缺少 crontab 命令。" >&2
exit 1
fi
tmp_file="$(mktemp)"
trap 'rm -f "$tmp_file"' EXIT
crontab -l 2>/dev/null | sed "/$MARK_BEGIN/,/$MARK_END/d" > "$tmp_file"
crontab "$tmp_file"
echo "已卸载证书续期 cron。"