From fb87be680d679aa2c73b97a805942edf82b7c7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=A8=8133321?= <33321@sangfor.com> Date: Tue, 8 Sep 2026 20:36:17 +0800 Subject: [PATCH] 0908 --- certbot/Dockerfile | 17 +- certbot/README.md | 154 +++++++++--------- certbot/check-cert.sh | 34 ++++ certbot/crontab.example | 11 +- certbot/docker-compose.yml | 14 +- certbot/init-cert.sh | 65 +++++--- certbot/renew-cert.sh | 47 +++--- nginx/Dockerfile | 36 ++-- nginx/conf.d/00-http.conf | 30 ++++ nginx/docker-compose.yml | 8 + nginx/enable-ssl.sh | 59 +++++++ nginx/nginx.conf | 63 +++---- .../{conf.d => ssl.d.available}/fastgpt.conf | 18 +- nginx/{conf.d => ssl.d.available}/gitea.conf | 3 +- nginx/{conf.d => ssl.d.available}/image.conf | 3 +- .../{conf.d => ssl.d.available}/registry.conf | 3 +- nginx/{conf.d => ssl.d.available}/www.conf | 3 +- nginx/ssl.d/.gitkeep | 2 + nginx/updateConfig.sh | 40 ++++- 19 files changed, 398 insertions(+), 212 deletions(-) create mode 100644 certbot/check-cert.sh create mode 100644 nginx/conf.d/00-http.conf create mode 100644 nginx/enable-ssl.sh rename nginx/{conf.d => ssl.d.available}/fastgpt.conf (78%) rename nginx/{conf.d => ssl.d.available}/gitea.conf (88%) rename nginx/{conf.d => ssl.d.available}/image.conf (88%) rename nginx/{conf.d => ssl.d.available}/registry.conf (88%) rename nginx/{conf.d => ssl.d.available}/www.conf (88%) create mode 100644 nginx/ssl.d/.gitkeep diff --git a/certbot/Dockerfile b/certbot/Dockerfile index 5ebe1a3..5458e49 100644 --- a/certbot/Dockerfile +++ b/certbot/Dockerfile @@ -1,20 +1,19 @@ # Certbot 镜像 - Let's Encrypt 证书管理 -FROM certbot/certbot:latest +# +# 【重要】官方 certbot/certbot 镜像的 ENTRYPOINT 是 ["certbot"],这里不要覆盖 +# ENTRYPOINT 或 CMD。一旦覆盖,容器启动命令会变成 `certbot <你的 CMD>`, +# certbot 无法解析而退出。正确用法是把容器当一次性任务跑: +# docker compose run --rm certbot certonly ... +# docker compose run --rm certbot renew +FROM certbot/certbot:v5.8.0 LABEL maintainer="awei" LABEL description="Let's Encrypt certificate management service" -# 安装额外工具 +# 调试用工具(如需 DNS-01 验证,在此追加 pip install certbot-dns-xxx) RUN apk add --no-cache curl bash # 创建必要目录 RUN mkdir -p /var/www/certbot /etc/letsencrypt /var/log/letsencrypt -# 设置工作目录 WORKDIR /var/www/certbot - -# 暴露端口(用于 ACME 验证) -EXPOSE 80 - -# 默认命令:启动 web 服务器用于 ACME 验证 -CMD ["sh", "-c", "while true; do echo 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nOK' | nc -l -p 80; done"] diff --git a/certbot/README.md b/certbot/README.md index 5309197..7e8c2ee 100644 --- a/certbot/README.md +++ b/certbot/README.md @@ -1,117 +1,117 @@ # Let's Encrypt 证书管理服务 -独立的 certbot 服务,用于管理 Let's Encrypt SSL证书的申请和自动续签。 +独立的 certbot 服务,管理 SSL 证书的申请与自动续签。 -## 架构说明 +## 架构 ``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ 用户请求 │───▶│ frps (远程) │───▶│ frpc (本地) │ -│ *.stonelan.cn │ │ 80/443端口 │ │ 80/8443端口 │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ nginx-proxy 容器 │ -│ - 80端口: 处理 ACME 验证请求,转发到 certbot 容器 │ -│ - 8443端口: HTTPS 反向代理,使用 Let's Encrypt 证书 │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ certbot 容器 │ -│ - 处理 ACME HTTP-01 验证 │ -│ - 定期续签证书 │ -│ - 续签后自动重载 nginx (通过 docker.sock) │ -└─────────────────────────────────────────────────────────────────┘ +用户请求 ──▶ frps(远程 80/443) ──▶ frpc ──▶ nginx-proxy (80 / 8443) + │ + ACME 验证 ◀── 直接读 certbot-webroot 卷 + HTTPS ◀── 读 certbot-certs 卷(证书) + HTTPS 配置 ◀── 挂载 ./ssl.d(证书就绪后才写入) ``` -## 文件说明 +certbot **不再常驻**:它只是一个一次性任务容器,通过 `docker compose run --rm` 执行。 -- `Dockerfile` - certbot 镜像定义 -- `docker-compose.yml` - certbot 容器配置 -- `init-cert.sh` - 初始化申请证书脚本 -- `renew-cert.sh` - 自动续签脚本 -- `crontab.example` - cron 配置示例 +## 为什么 HTTPS 配置要单独放 + +nginx 在加载配置时就要把证书读进内存。如果镜像里直接烤入带 `ssl_certificate` 的 +server 块,首次部署时证书不存在,nginx 会 `[emerg] cannot load certificate` 直接退出; +而申请证书又必须要有 80 端口 —— 形成死锁。 + +所以拆成两段: + +| 目录 | 内容 | 何时加载 | +|---|---|---| +| `nginx/conf.d/00-http.conf` | 80 端口:ACME 验证 + 301 跳转 + /healthz | 烤进镜像,**始终加载** | +| `nginx/ssl.d.available/*.conf` | 8443 端口的 HTTPS server 块(模板) | 不直接加载 | +| `nginx/ssl.d/*.conf` | 由 `enable-ssl.sh` 从上面复制过来 | 挂进容器,证书就绪后才有内容 | + +`nginx.conf` 末尾的 `include /etc/nginx/ssl.d/*.conf;` 在目录为空时不报错, +因此冷启动阶段 nginx 只加载 80 端口配置,可以正常启动。 ## 使用方法 -### 1. 首次申请证书 +### 1. 首次申请证书(一键引导) ```bash -cd /home/awei/workspace/common-server-public/certbot +cd common-server-public/certbot +chmod +x init-cert.sh renew-cert.sh check-cert.sh ../nginx/enable-ssl.sh +./init-cert.sh +``` -# 为每个域名申请证书 -./init-cert.sh fastgpt.stonelan.cn admin@example.com -./init-cert.sh gitea.stonelan.cn admin@example.com -./init-cert.sh image.stonelan.cn admin@example.com -./init-cert.sh registry.stonelan.cn admin@example.com -./init-cert.sh www.stonelan.cn admin@example.com +脚本依次完成:创建共享卷 → 启动 nginx(此时无 HTTPS 配置)→ 申请证书 → +把已就绪域名的 HTTPS 配置写入 `ssl.d/` 并重载 nginx。 + +默认 5 个域名合成**一张 SAN 证书**,可用环境变量覆盖: + +```bash +EMAIL=you@example.com \ +DOMAINS="fastgpt.stonelan.cn gitea.stonelan.cn image.stonelan.cn registry.stonelan.cn www.stonelan.cn" \ +./init-cert.sh ``` ### 2. 配置自动续签 ```bash -# 编辑 crontab crontab -e - -# 添加以下行(每天凌晨 2 点检查续签) -0 2 * * * cd /home/awei/workspace/common-server-public/certbot && ./renew-cert.sh >> /var/log/certbot-renew.log 2>&1 +# 参考 crontab.example: +# 0 2,15 * * * bash <绝对路径>/renew-cert.sh >> /var/log/certbot-renew.log 2>&1 +# 0 8 * * * bash <绝对路径>/check-cert.sh >> /var/log/certbot-check.log 2>&1 ``` -### 3. 手动续签测试 +- `renew-cert.sh`:续签,然后给 nginx 发 HUP 优雅重载 +- `check-cert.sh`:剩余有效期低于 20 天时以非 0 退出,用于接告警 + +### 3. 手动操作 ```bash -cd /home/awei/workspace/common-server-public/certbot -./renew-cert.sh +./renew-cert.sh # 手动续签 +./check-cert.sh # 查看证书与剩余天数 +../nginx/enable-ssl.sh # 单独启用 HTTPS 配置(部分域名没证书时会跳过) +docker compose run --rm certbot certificates ``` -## 证书路径 +## 目录与卷 -证书存储在 Docker volume `certbot-certs` 中,nginx 容器通过共享 volume 访问: +| 卷 / 目录 | 容器内路径 | 使用者 | 说明 | +|---|---|---|---| +| `certbot-certs` | `/etc/letsencrypt` | certbot 读写、nginx 只读 | 证书 | +| `certbot-webroot` | `/var/www/certbot` | certbot 写、nginx 只读 | ACME 挑战文件 | +| `certbot-logs` | `/var/log/letsencrypt` | certbot | 日志 | +| `./nginx/ssl.d` | `/etc/nginx/ssl.d` | nginx 只读 | HTTPS server 块 | -- 证书: `/etc/letsencrypt/live/<域名>/fullchain.pem` -- 私钥: `/etc/letsencrypt/live/<域名>/privkey.pem` +卷名在 `certbot/docker-compose.yml` 里用 `name:` 固定(compose 默认会加项目名前缀, +两个 compose 项目目录不同,不固定会导致 nginx 侧 `external: true` 找不到卷)。 -## 端口映射 +## 端口 -| 服务 | 容器端口 | 宿主机端口 | 说明 | -|------|----------|------------|------| -| nginx | 80 | 8081 | HTTP (ACME验证 + 重定向) | -| nginx | 8443 | 8443 | HTTPS (SSL反向代理) | -| frpc | 80 | 80 (远程) | HTTP 映射 | -| frpc | 8443 | 443 (远程) | HTTPS 映射 | - -## 重启 nginx - -续签后会自动通过 `docker kill -s HUP nginx-proxy` 重载 nginx。如需手动重启: - -```bash -docker kill -s HUP nginx-proxy -# 或 -docker restart nginx-proxy -``` +| 服务 | 容器端口 | 宿主机端口 | 远程端口 | +|---|---|---|---| +| nginx | 80 | 8081 | 80 | +| nginx | 8443 | 8443 | 443 | ## 故障排查 -### 查看证书状态 ```bash -docker compose exec certbot certbot certificates -``` +# nginx 是否只加载了 HTTP 配置(冷启动预期如此) +docker exec nginx-proxy nginx -T | grep -c "listen 8443" -### 查看续签日志 -```bash +# ACME 验证路径是否可达(申请期间在容器内建个测试文件) +docker exec nginx-proxy sh -c 'echo probe > /var/www/certbot/.well-known/acme-challenge/test' 2>/dev/null || true +curl -i http://fastgpt.stonelan.cn/.well-known/acme-challenge/test + +# 查看证书 / 续签日志 +docker compose run --rm certbot certificates tail -f /var/log/certbot-renew.log ``` -### 检查 ACME 验证是否可达 -```bash -curl http://fastgpt.stonelan.cn/.well-known/acme-challenge/test -``` - ## 注意事项 -1. 确保域名 DNS 已正确解析到 frps 服务器 IP -2. 确保 frpc 和 nginx 容器在同一个 Docker 网络 (`trim-default`) -3. 证书有效期 90 天,建议每 60 天续签一次(cron 已配置每天检查) -4. 首次申请证书前,确保 nginx 容器已启动并能处理 ACME 验证请求 +1. 域名 DNS 需解析到 frps 服务器,且 frpc 与 nginx 在同一 Docker 网络 `trim-default` +2. **不要**在命令里重复写 `certbot`:`docker compose run --rm certbot renew` 才是正确写法 + (官方镜像 ENTRYPOINT 已经是 certbot,写成 `run --rm certbot certbot renew` 会失败) +3. 证书有效期 90 天,certbot 默认在剩余 30 天内才真正续签 +4. `init-cert.sh` 使用 `--keep-until-expiring`,不要改成 `--force-renewal`, + 否则每次执行都重签,很容易撞 Let's Encrypt 的限流(同一组域名每周 5 张) diff --git a/certbot/check-cert.sh b/certbot/check-cert.sh new file mode 100644 index 0000000..bc7cf62 --- /dev/null +++ b/certbot/check-cert.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# 查看证书状态;任一证书剩余有效期低于阈值时以非 0 退出,便于接入告警。 +# 证书过期是静默事故,光有续签日志不够,必须有一个独立的检查点。 +# +set -euo pipefail + +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WARN_DAYS="${WARN_DAYS:-20}" +cd "$SCRIPT_DIR" + +if ! out=$(docker compose run --rm certbot certificates 2>&1); then + echo "$out" + echo "获取证书信息失败" >&2 + exit 1 +fi +echo "$out" + +# certbot 输出形如:Expiry Date: 2026-12-07 02:41:23+00:00 (VALID: 89 days) +min="$(echo "$out" | grep -o 'VALID: [0-9]*' | awk '{print $2}' | sort -n | head -1 || true)" + +if [ -z "$min" ]; then + echo "未能解析证书有效期" >&2 + exit 1 +fi + +if [ "$min" -lt "$WARN_DAYS" ]; then + echo "警告:证书最少剩余 $min 天,低于阈值 $WARN_DAYS 天" >&2 + exit 1 +fi + +echo "证书状态正常,最少剩余 $min 天" diff --git a/certbot/crontab.example b/certbot/crontab.example index 9302d39..cb8bb9d 100644 --- a/certbot/crontab.example +++ b/certbot/crontab.example @@ -1,7 +1,10 @@ # Let's Encrypt 自动续签 cron 配置 -# 每天凌晨 2 点执行续签检查 +# 编辑:crontab -e +# +# 注意:cron 的 PATH 极简,务必用 bash <绝对路径> 调用(脚本内部也会自行补齐 PATH) -# 编辑 crontab: crontab -e -# 添加以下行: +# 续签:LE 官方建议一天两次 +0 2,15 * * * bash /home/awei/workspace/common-server-public/certbot/renew-cert.sh >> /var/log/certbot-renew.log 2>&1 -0 2 * * * cd /home/awei/workspace/common-server-public/certbot && ./renew-cert.sh >> /var/log/certbot-renew.log 2>&1 +# 有效期检查:低于 20 天时脚本以非 0 退出,可配合告警 +0 8 * * * bash /home/awei/workspace/common-server-public/certbot/check-cert.sh >> /var/log/certbot-check.log 2>&1 diff --git a/certbot/docker-compose.yml b/certbot/docker-compose.yml index 1baf02f..e06f843 100644 --- a/certbot/docker-compose.yml +++ b/certbot/docker-compose.yml @@ -1,22 +1,30 @@ services: certbot: build: . - container_name: certbot - restart: unless-stopped + image: common-server-certbot:latest + # 一次性任务容器,不常驻: + # docker compose run --rm certbot certonly ... # 申请 + # docker compose run --rm certbot renew # 续签 + # 注意:官方镜像 ENTRYPOINT 已经是 certbot,命令里不要再写一遍 certbot。 volumes: - certbot-webroot:/var/www/certbot - certbot-certs:/etc/letsencrypt - certbot-logs:/var/log/letsencrypt - - /var/run/docker.sock:/var/run/docker.sock:ro networks: - trim-default volumes: + # 固定物理卷名,供 nginx/docker-compose.yml 以 external 方式引用。 + # compose 默认会给卷名加项目名前缀,两个 compose 项目目录不同, + # 不显式指定 name 会导致两侧指向不同的卷。 certbot-webroot: + name: certbot-webroot driver: local certbot-certs: + name: certbot-certs driver: local certbot-logs: + name: certbot-logs driver: local networks: diff --git a/certbot/init-cert.sh b/certbot/init-cert.sh index fc79880..ec426a6 100755 --- a/certbot/init-cert.sh +++ b/certbot/init-cert.sh @@ -1,35 +1,52 @@ -#!/bin/bash -# Let's Encrypt 证书初始化脚本 -# 用法: ./init-cert.sh <域名> [邮箱] +#!/usr/bin/env bash +# +# 申请 Let's Encrypt 证书(默认 5 个域名合成一张 SAN 证书)。 +# +# 完整引导流程: +# 1. 创建共享卷(幂等)—— nginx 以 external 方式引用,必须先存在 +# 2. 启动 nginx —— 此时 ssl.d/ 为空,只加载 80 端口配置,不依赖证书 +# 3. 申请证书 —— webroot 写入挑战文件,由 nginx 直接对外提供 +# 4. 启用 HTTPS —— 证书就绪后把 server 块放进 ssl.d/ 并重载 +# +set -euo pipefail -set -e +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" -DOMAIN=$1 -EMAIL=${2:-"admin@stonelan.cn"} +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NGINX_DIR="$SCRIPT_DIR/../nginx" -if [ -z "$DOMAIN" ]; then - echo "用法: $0 <域名> [邮箱]" - echo "示例: $0 fastgpt.stonelan.cn admin@example.com" - exit 1 -fi +EMAIL="${EMAIL:-admin@stonelan.cn}" +DOMAINS="${DOMAINS:-fastgpt.stonelan.cn gitea.stonelan.cn image.stonelan.cn registry.stonelan.cn www.stonelan.cn}" -echo "开始为域名 $DOMAIN 申请证书..." +args=() +for d in $DOMAINS; do + args+=(-d "$d") +done -# 确保 certbot 容器运行 -docker compose up -d +cd "$SCRIPT_DIR" -# 等待容器启动 -sleep 5 +# 1. 共享卷 +for v in certbot-certs certbot-webroot certbot-logs; do + docker volume create "$v" >/dev/null +done -# 申请证书(使用 webroot 验证方式) -docker compose exec certbot certbot certonly \ +# 2. nginx(无证书也能起来) +docker compose -f "$NGINX_DIR/docker-compose.yml" up -d + +# 3. 申请证书 +# --keep-until-expiring:证书离到期还远就跳过,避免撞 LE 的重复证书限流 +# (原脚本的 --force-renewal 每次都强制重签,同一组域名每周只有 5 次额度) +echo "==> 申请证书:$DOMAINS" +docker compose run --rm certbot certonly \ --webroot \ - --webroot-path=/var/www/certbot \ - --email "$EMAIL" \ + --webroot-path /var/www/certbot \ + --non-interactive \ --agree-tos \ --no-eff-email \ - -d "$DOMAIN" \ - --force-renewal + --email "$EMAIL" \ + --keep-until-expiring \ + "${args[@]}" -echo "证书申请完成!" -echo "证书路径: /etc/letsencrypt/live/$DOMAIN/" +# 4. 启用 HTTPS +echo "==> 启用 HTTPS 配置" +exec bash "$NGINX_DIR/enable-ssl.sh" diff --git a/certbot/renew-cert.sh b/certbot/renew-cert.sh index 5025375..a7aefa4 100755 --- a/certbot/renew-cert.sh +++ b/certbot/renew-cert.sh @@ -1,27 +1,34 @@ -#!/bin/bash -# Let's Encrypt 证书自动续签脚本 -# 续签后自动重载 nginx +#!/usr/bin/env bash +# +# 检查证书续签,并重载 nginx。 +# +# 说明:certbot renew 无论是否真的续签都返回 0,无法用退出码判断。 +# 这里不做判断,统一发 HUP —— nginx 的 HUP 是优雅 reload,代价极低, +# 顺带还能刷新 nginx 对上游的 DNS 缓存。 +# (若要用 --deploy-hook,钩子是在容器内部执行的,就得把 docker.sock +# 挂进容器,等于把宿主机 root 交给容器,不值得。) +# +set -euo pipefail -set -e +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" -echo "开始检查证书续签..." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" -# 执行续签 -docker compose exec certbot certbot renew \ +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +log "开始续签检查" + +docker compose run --rm certbot renew \ --webroot \ - --webroot-path=/var/www/certbot \ - --quiet + --webroot-path /var/www/certbot \ + --quiet \ + --no-random-sleep-on-renew -# 检查是否有证书被续签 -if [ $? -eq 0 ]; then - echo "证书续签检查完成" - - # 重载 nginx(发送 HUP 信号) - echo "重载 nginx 配置..." - docker kill -s HUP nginx-proxy 2>/dev/null || echo "nginx-proxy 容器未运行或不存在" - - echo "续签流程完成" +if docker kill -s HUP nginx-proxy 2>/dev/null; then + log "nginx 已重载" else - echo "证书续签失败" - exit 1 + log "警告:nginx-proxy 未运行,跳过重载" >&2 fi + +log "续签检查完成" diff --git a/nginx/Dockerfile b/nginx/Dockerfile index 14479f2..3c37d2a 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -1,27 +1,27 @@ -# Nginx Docker image -# 包含自定义配置的反向代理 +# 使用官方 Nginx 镜像 +FROM nginx:09051708 -FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:alpine +# 维护者信息 +LABEL maintainer="awei" +LABEL description="HTTP server with SSL support" -LABEL maintainer="StoneLan" -LABEL description="Nginx reverse proxy container" - -# 复制 nginx 配置 +# 复制配置文件 +# conf.d 只放不依赖证书的常驻配置(80 端口:ACME 验证 + HTTPS 跳转) COPY nginx.conf /etc/nginx/nginx.conf COPY conf.d /etc/nginx/conf.d +COPY verification.html /usr/share/nginx/html/verification.html -# 复制静态文件 -COPY html /usr/share/nginx/html +# 创建日志目录与 HTTPS 配置目录 +# ssl.d 必须存在:nginx.conf 里的 wildcard include 在目录不存在时会报 emerg +RUN mkdir -p /var/log/nginx /etc/nginx/ssl.d -# 创建必要的目录 -RUN mkdir -p /var/log/nginx && \ - mkdir -p /etc/nginx/ssl && \ - chown -R nginx:nginx /var/log/nginx && \ - chown -R nginx:nginx /etc/nginx +# 暴露端口 +EXPOSE 80 +EXPOSE 8443 -EXPOSE 80 443 - -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 +# 健康检查:/healthz 固定返回 200,不跟随 301,冷启动阶段也不会误判 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 -O /dev/null http://127.0.0.1/healthz || exit 1 +# 启动 Nginx CMD ["nginx", "-g", "daemon off;"] diff --git a/nginx/conf.d/00-http.conf b/nginx/conf.d/00-http.conf new file mode 100644 index 0000000..95b50be --- /dev/null +++ b/nginx/conf.d/00-http.conf @@ -0,0 +1,30 @@ +# HTTP 入口:ACME 验证 + HTTPS 跳转 +# +# 【关键】这个文件必须常驻,且不能引用任何证书文件。 +# 首次部署时证书尚不存在,如果 nginx 加载了带 ssl_certificate 的 server 块, +# 会直接 [emerg] cannot load certificate 退出;而申请证书又必须要有 80 端口可用 +# —— 形成死锁。因此所有 HTTPS server 块统一放到 /etc/nginx/ssl.d/, +# 由 nginx/enable-ssl.sh 在证书就绪后再启用。 +server { + listen 80 default_server; + server_name _; + + # 健康检查探针(容器 HEALTHCHECK 使用),不跟随 301 + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # ACME HTTP-01 验证 + # certbot 通过 webroot 写入 /var/www/certbot/.well-known/acme-challenge/ + # 该目录是 certbot-webroot 卷,nginx 以只读方式挂载 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type text/plain; + } + + location / { + return 301 https://$host$request_uri; + } +} diff --git a/nginx/docker-compose.yml b/nginx/docker-compose.yml index d42e19e..7bd5c8b 100644 --- a/nginx/docker-compose.yml +++ b/nginx/docker-compose.yml @@ -6,14 +6,22 @@ services: - "8081:80" - "8443:8443" volumes: + # 证书(只读) - certbot-certs:/etc/letsencrypt:ro + # ACME 验证目录(只读):certbot 写入挑战文件,nginx 直接对外提供 + - certbot-webroot:/var/www/certbot:ro + # HTTPS server 块:默认为空,证书就绪后由 enable-ssl.sh 写入 + - ./ssl.d:/etc/nginx/ssl.d:ro restart: unless-stopped networks: - trim-default volumes: + # 与 certbot/docker-compose.yml 共用,物理卷名由 certbot 侧固定为同名 certbot-certs: external: true + certbot-webroot: + external: true networks: trim-default: diff --git a/nginx/enable-ssl.sh b/nginx/enable-ssl.sh new file mode 100644 index 0000000..a59750f --- /dev/null +++ b/nginx/enable-ssl.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# 启用 HTTPS 配置:只把"已经拿到证书"的域名对应的 server 块 +# 从 ssl.d.available/ 复制到 ssl.d/(nginx 挂载并 include 的目录), +# 然后校验配置并优雅重载 nginx。 +# +# 冷启动时 ssl.d/ 为空,nginx 只加载 conf.d/00-http.conf(不引用证书), +# 因此可以在没有证书的情况下正常启动并提供 80 端口 —— 这是打破 +# "先有证书还是先有 nginx" 死锁的关键。 +# +set -euo pipefail + +# cron / 非登录 shell 下 PATH 极简,显式补齐 +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AVAILABLE="$SCRIPT_DIR/ssl.d.available" +ENABLED="$SCRIPT_DIR/ssl.d" +DOMAINS="${DOMAINS:-fastgpt.stonelan.cn gitea.stonelan.cn image.stonelan.cn registry.stonelan.cn www.stonelan.cn}" + +mkdir -p "$ENABLED" +cd "$SCRIPT_DIR" + +copied=() +for d in $DOMAINS; do + conf="$AVAILABLE/$d.conf" + if [ ! -f "$conf" ]; then + echo "跳过 $d:缺少 $conf" + continue + fi + + # nginx 容器以只读方式挂载了 certbot-certs 卷,用它判断证书是否就绪 + if ! docker compose exec -T nginx-proxy test -f "/etc/letsencrypt/live/$d/fullchain.pem" 2>/dev/null; then + echo "跳过 $d:证书不存在(/etc/letsencrypt/live/$d/fullchain.pem)" + continue + fi + + if ! cmp -s "$conf" "$ENABLED/$d.conf" 2>/dev/null; then + cp "$conf" "$ENABLED/$d.conf" + copied+=("$ENABLED/$d.conf") + echo "已启用 $d" + fi +done + +if [ "${#copied[@]}" -eq 0 ]; then + echo "没有需要变更的 HTTPS 配置" + exit 0 +fi + +if out=$(docker compose exec -T nginx-proxy nginx -t 2>&1); then + echo "$out" | sed 's/^/ /' + docker kill -s HUP nginx-proxy + echo "nginx 已重载,HTTPS 配置生效" +else + echo "$out" | sed 's/^/ /' + echo "nginx 配置校验失败,回滚本次变更" >&2 + rm -f "${copied[@]}" + exit 1 +fi diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 199b9af..ac199ec 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -1,29 +1,34 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log notice; -pid /var/run/nginx.pid; - - -events { - worker_connections 1024; -} - - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - include /etc/nginx/conf.d/*.conf; - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - #tcp_nopush on; - - keepalive_timeout 65; - - -} +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + include /etc/nginx/conf.d/*.conf; + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + # HTTPS server 块按需加载: + # 该目录默认为空(nginx 的 wildcard include 匹配 0 个文件时不报错), + # 证书申请成功后由 enable-ssl.sh 写入配置,从而避免冷启动时 + # 因证书文件不存在导致 nginx 拒绝启动。 + include /etc/nginx/ssl.d/*.conf; + +} diff --git a/nginx/conf.d/fastgpt.conf b/nginx/ssl.d.available/fastgpt.conf similarity index 78% rename from nginx/conf.d/fastgpt.conf rename to nginx/ssl.d.available/fastgpt.conf index cbba6dd..6c2cc5f 100644 --- a/nginx/conf.d/fastgpt.conf +++ b/nginx/ssl.d.available/fastgpt.conf @@ -1,18 +1,6 @@ -# HTTP -> HTTPS 重定向(ACME 验证由独立 certbot 服务处理) -server { - listen 80; - server_name fastgpt.stonelan.cn gitea.stonelan.cn image.stonelan.cn registry.stonelan.cn www.stonelan.cn; - - location /.well-known/acme-challenge/ { - proxy_pass http://certbot:80; - } - - location / { - return 301 https://$host$request_uri; - } -} - -# HTTPS 配置 +# HTTPS 配置 —— fastgpt.stonelan.cn +# 仅在 /etc/letsencrypt/live/fastgpt.stonelan.cn/fullchain.pem 存在时, +# 由 nginx/enable-ssl.sh 复制到 ssl.d/ 才会被 nginx 加载。 server { listen 8443 ssl; server_name fastgpt.stonelan.cn; diff --git a/nginx/conf.d/gitea.conf b/nginx/ssl.d.available/gitea.conf similarity index 88% rename from nginx/conf.d/gitea.conf rename to nginx/ssl.d.available/gitea.conf index d11693f..6a15b01 100644 --- a/nginx/conf.d/gitea.conf +++ b/nginx/ssl.d.available/gitea.conf @@ -1,4 +1,5 @@ -# HTTPS 配置 +# HTTPS 配置 —— gitea.stonelan.cn +# 仅在证书存在时由 nginx/enable-ssl.sh 复制到 ssl.d/ 才会生效。 server { listen 8443 ssl; server_name gitea.stonelan.cn; diff --git a/nginx/conf.d/image.conf b/nginx/ssl.d.available/image.conf similarity index 88% rename from nginx/conf.d/image.conf rename to nginx/ssl.d.available/image.conf index 7e95585..ee43e3d 100644 --- a/nginx/conf.d/image.conf +++ b/nginx/ssl.d.available/image.conf @@ -1,4 +1,5 @@ -# HTTPS 配置 +# HTTPS 配置 —— image.stonelan.cn +# 仅在证书存在时由 nginx/enable-ssl.sh 复制到 ssl.d/ 才会生效。 server { listen 8443 ssl; server_name image.stonelan.cn; diff --git a/nginx/conf.d/registry.conf b/nginx/ssl.d.available/registry.conf similarity index 88% rename from nginx/conf.d/registry.conf rename to nginx/ssl.d.available/registry.conf index 93e5cf7..96190c9 100644 --- a/nginx/conf.d/registry.conf +++ b/nginx/ssl.d.available/registry.conf @@ -1,4 +1,5 @@ -# HTTPS 配置 +# HTTPS 配置 —— registry.stonelan.cn +# 仅在证书存在时由 nginx/enable-ssl.sh 复制到 ssl.d/ 才会生效。 server { listen 8443 ssl; server_name registry.stonelan.cn; diff --git a/nginx/conf.d/www.conf b/nginx/ssl.d.available/www.conf similarity index 88% rename from nginx/conf.d/www.conf rename to nginx/ssl.d.available/www.conf index 2d06e35..14f17fd 100644 --- a/nginx/conf.d/www.conf +++ b/nginx/ssl.d.available/www.conf @@ -1,4 +1,5 @@ -# HTTPS 配置 +# HTTPS 配置 —— www.stonelan.cn +# 仅在证书存在时由 nginx/enable-ssl.sh 复制到 ssl.d/ 才会生效。 server { listen 8443 ssl; server_name www.stonelan.cn; diff --git a/nginx/ssl.d/.gitkeep b/nginx/ssl.d/.gitkeep new file mode 100644 index 0000000..986d8a4 --- /dev/null +++ b/nginx/ssl.d/.gitkeep @@ -0,0 +1,2 @@ +# nginx 从这里加载 HTTPS 配置(wildcard include,目录为空时 nginx 正常启动)。 +# 首次申请证书前本目录为空,由 ../enable-ssl.sh 按需写入。 diff --git a/nginx/updateConfig.sh b/nginx/updateConfig.sh index dcc53ec..789ca42 100644 --- a/nginx/updateConfig.sh +++ b/nginx/updateConfig.sh @@ -1,4 +1,8 @@ -#!/bin/bash +#!/usr/bin/env bash +# +# 把仓库里的 nginx 配置同步到宿主机 nginx(非容器部署路径)。 +# +set -euo pipefail # 检查并创建nginx用户 if ! id -u nginx >/dev/null 2>&1; then @@ -6,31 +10,49 @@ if ! id -u nginx >/dev/null 2>&1; then sudo useradd -r -s /sbin/nologin nginx fi -# 创建 conf.d 目录(如果不存在) -sudo mkdir -p /etc/nginx/conf.d +# 创建配置目录(ssl.d 必须存在,nginx.conf 里的 wildcard include 在目录缺失时会报 emerg) +sudo mkdir -p /etc/nginx/conf.d /etc/nginx/ssl.d # 备份并覆盖 nginx.conf 文件 sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak sudo cp nginx.conf /etc/nginx/ -# 备份并覆盖 conf.d 目录下的所有 .conf 文件 +# 备份并覆盖 conf.d 目录下的所有 .conf 文件(只放不依赖证书的配置) for conf in conf.d/*.conf; do filename=$(basename "$conf") sudo cp "/etc/nginx/conf.d/$filename" "/etc/nginx/conf.d/${filename}.bak" 2>/dev/null || true sudo cp "$conf" /etc/nginx/conf.d/ done +# HTTPS 配置:只有证书已就绪的域名才启用,避免 nginx 因证书缺失拒绝启动 +DOMAINS="${DOMAINS:-fastgpt.stonelan.cn gitea.stonelan.cn image.stonelan.cn registry.stonelan.cn www.stonelan.cn}" +for d in $DOMAINS; do + src="ssl.d.available/$d.conf" + [ -f "$src" ] || { echo "跳过 $d:缺少 $src"; continue; } + + if [ -f "/etc/letsencrypt/live/$d/fullchain.pem" ]; then + sudo cp "$src" "/etc/nginx/ssl.d/$d.conf" + echo "已启用 $d" + else + echo "跳过 $d:证书尚未生成" + fi +done + # 测试并重载配置 if sudo nginx -t; then - sudo systemctl reload nginx - echo "配置已生效,HTTPS已启用" + if sudo systemctl is-active --quiet nginx; then + sudo systemctl reload nginx + echo "配置已重载" + else + sudo systemctl start nginx + echo "nginx 已启动" + fi else echo "配置测试失败,已恢复备份" - # 恢复 nginx.conf 备份 sudo cp /etc/nginx/nginx.conf.bak /etc/nginx/nginx.conf - # 恢复 conf.d 目录下的所有 .conf 文件备份 for conf in conf.d/*.conf; do filename=$(basename "$conf") sudo mv -f "/etc/nginx/conf.d/${filename}.bak" "/etc/nginx/conf.d/$filename" 2>/dev/null || true done -fi \ No newline at end of file + exit 1 +fi