From a9ec40a289392e2163a64fd94994b7cf44366ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=A8=8133321?= <33321@sangfor.com> Date: Wed, 9 Sep 2026 15:04:54 +0800 Subject: [PATCH] 0908 --- acme/README.md | 98 +++++++++++++++++++++++ acme/check-cert.sh | 32 ++++++++ acme/crontab.example | 10 +++ acme/install.sh | 25 ++++++ acme/issue.sh | 61 +++++++++++++++ acme/renew.sh | 20 +++++ certbot/Dockerfile | 19 ----- certbot/README.md | 117 ---------------------------- certbot/check-cert.sh | 34 -------- certbot/crontab.example | 10 --- certbot/docker-compose.yml | 32 -------- certbot/init-cert.sh | 52 ------------- certbot/renew-cert.sh | 34 -------- nginx/conf.d/00-http.conf | 4 +- nginx/docker-compose.yml | 15 +--- nginx/enable-ssl.sh | 28 +++---- nginx/ssl.d.available/fastgpt.conf | 8 +- nginx/ssl.d.available/gitea.conf | 7 +- nginx/ssl.d.available/image.conf | 7 +- nginx/ssl.d.available/registry.conf | 7 +- nginx/ssl.d.available/www.conf | 7 +- nginx/updateConfig.sh | 28 ++++--- 22 files changed, 303 insertions(+), 352 deletions(-) create mode 100644 acme/README.md create mode 100644 acme/check-cert.sh create mode 100644 acme/crontab.example create mode 100644 acme/install.sh create mode 100644 acme/issue.sh create mode 100644 acme/renew.sh delete mode 100644 certbot/Dockerfile delete mode 100644 certbot/README.md delete mode 100644 certbot/check-cert.sh delete mode 100644 certbot/crontab.example delete mode 100644 certbot/docker-compose.yml delete mode 100755 certbot/init-cert.sh delete mode 100755 certbot/renew-cert.sh diff --git a/acme/README.md b/acme/README.md new file mode 100644 index 0000000..5f3647b --- /dev/null +++ b/acme/README.md @@ -0,0 +1,98 @@ +# 证书管理服务(acme.sh) + +用宿主机上的 [acme.sh](https://github.com/acmesh-official/acme.sh) 管理 Let's Encrypt 证书: +纯 Shell 实现,不依赖 Python / snap / Docker 容器。 + +## 为什么换成 acme.sh + +之前用 certbot 容器时有个绕不开的别扭点:certbot 的 `--deploy-hook` 在**容器内**执行, +而我们要 reload 的 nginx 是**宿主机上的容器**,只能在容器里挂 `docker.sock`(等于把宿主机 +root 交给容器),或者退而求其次每天无条件发一次 HUP。 + +acme.sh 直接跑在宿主机,`--reloadcmd` 天然在宿主机执行,**只在证书真正更新时才 reload**, +顺带把 certbot 容器、Dockerfile、docker.sock、compose PATH 问题全部消掉。 + +## 架构 + +``` +宿主机 容器 +───────────────────────────────────── ────────────────────────── +acme.sh(~/.acme.sh,自带 cron) + ├─ 写挑战文件 ──▶ /var/www/certbot ──bind mount(ro)──▶ nginx-proxy + ├─ 写证书 ──▶ /etc/letsencrypt ──bind mount(ro)──▶ nginx-proxy + └─ 续签后执行 reloadcmd: docker kill -s HUP nginx-proxy + +用户 ─▶ frps(80/443) ─▶ frpc ─▶ nginx-proxy(80/8443) +``` + +## 目录约定 + +| 宿主机路径 | 用途 | +|---|---| +| `/var/www/certbot` | ACME HTTP-01 挑战文件 | +| `/etc/letsencrypt/live//` | 证书(默认 CERT_NAME=stonelan.cn) | +| `nginx/ssl.d/` | HTTPS server 块,证书就绪后由 `enable-ssl.sh` 写入 | + +5 个域名合成**一张 SAN 证书**,放在同一个目录,nginx 的 5 个 server 块都引用它。 + +## 使用方法 + +### 1. 安装 + +```bash +cd common-server-public/acme +chmod +x install.sh issue.sh renew.sh check-cert.sh +./install.sh +``` + +### 2. 申请证书(一键引导) + +```bash +sudo ./issue.sh +``` + +依次完成:建目录 → 起 nginx(此时无 HTTPS 配置)→ 申请证书 → 安装证书 → +写入 `ssl.d/` 并重载 nginx。 + +可用环境变量覆盖: + +```bash +EMAIL=you@example.com \ +CERT_NAME=stonelan.cn \ +DOMAINS="fastgpt.stonelan.cn gitea.stonelan.cn image.stonelan.cn registry.stonelan.cn www.stonelan.cn" \ +sudo -E ./issue.sh +``` + +### 3. 自动续签 + +`install.sh` 已让 acme.sh 自己写入 cron,**无需额外配置**。 + +建议再加一条兜底告警(`crontab.example`): + +```cron +0 8 * * * bash <绝对路径>/check-cert.sh >> /var/log/acme-check.log 2>&1 +``` + +## 故障排查 + +```bash +acme.sh --list # 已申请的证书 +acme.sh --cron --debug 2 # 带调试跑一次续签 +./check-cert.sh # 剩余天数 + +# 确认 nginx 只加载了 HTTP 配置(冷启动时属正常) +docker exec nginx-proxy nginx -T | grep -c "listen 8443" + +# ACME 路径可达性 +echo probe | sudo tee /var/www/certbot/.well-known/acme-challenge/test +curl -i http://fastgpt.stonelan.cn/.well-known/acme-challenge/test +``` + +## 注意事项 + +1. 需要 root(或 sudo)运行:要写 `/etc/letsencrypt` 和 `/var/www` +2. nginx 容器 bind mount 的是**宿主机路径**,宿主机上这两个目录必须存在, + 否则 docker 会自动创建成 root 所有的空目录 +3. 挑战文件需要让容器内 nginx 可读,目录保持 755(`issue.sh` 已处理) +4. CA 已显式固定为 Let's Encrypt(acme.sh 3.x 默认是 ZeroSSL) +5. 证书有效期 90 天,acme.sh 默认在剩余 30 天内续签 diff --git a/acme/check-cert.sh b/acme/check-cert.sh new file mode 100644 index 0000000..2aa2beb --- /dev/null +++ b/acme/check-cert.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# 检查证书剩余有效期,低于阈值时以非 0 退出,便于接入告警。 +# 自动续签有可能静默失败,必须有一个独立的检查点。 +# +set -euo pipefail + +CERT_NAME="${CERT_NAME:-stonelan.cn}" +CERT="${CERT:-/etc/letsencrypt/live/$CERT_NAME/cert.pem}" +WARN_DAYS="${WARN_DAYS:-20}" + +if [ ! -f "$CERT" ]; then + echo "证书不存在:$CERT" >&2 + exit 1 +fi + +end="$(openssl x509 -enddate -noout -in "$CERT" | cut -d= -f2)" +end_ts="$(date -d "$end" +%s)" +days=$(( (end_ts - $(date +%s)) / 86400 )) + +echo "证书文件:$CERT" +echo "到期时间:$end(剩余 $days 天)" +openssl x509 -subject -issuer -noout -in "$CERT" | sed 's/^/ /' +openssl x509 -text -noout -in "$CERT" \ + | grep -A1 "Subject Alternative Name" | sed 's/^/ /' + +if [ "$days" -lt "$WARN_DAYS" ]; then + echo "警告:剩余 $days 天,低于阈值 $WARN_DAYS 天" >&2 + exit 1 +fi + +echo "证书状态正常" diff --git a/acme/crontab.example b/acme/crontab.example new file mode 100644 index 0000000..70b7bde --- /dev/null +++ b/acme/crontab.example @@ -0,0 +1,10 @@ +# acme.sh 安装时会自动写入一条 cron(每天执行 ~/.acme.sh/acme.sh --cron), +# 续签成功后自动执行 --reloadcmd,所以续签本身不需要再配 cron。 +# +# 这里只需要一个"有效期检查"作为兜底告警: +# 自动续签可能静默失败,剩余天数低于 20 天时脚本以非 0 退出。 + +0 8 * * * bash /home/awei/workspace/common-server-public/acme/check-cert.sh >> /var/log/acme-check.log 2>&1 + +# 如需强制每天显式跑一次续签(一般不必),可加: +# 0 3 * * * bash /home/awei/workspace/common-server-public/acme/renew.sh >> /var/log/acme-renew.log 2>&1 diff --git a/acme/install.sh b/acme/install.sh new file mode 100644 index 0000000..1c352df --- /dev/null +++ b/acme/install.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# +# 安装 acme.sh(纯 Shell ACME 客户端,不依赖 Python / 容器 / snap) +# +set -euo pipefail + +EMAIL="${EMAIL:-admin@stonelan.cn}" +ACME="${ACME:-$HOME/.acme.sh/acme.sh}" + +if [ -s "$ACME" ]; then + echo "acme.sh 已安装:$ACME" +else + echo "==> 安装 acme.sh" + curl https://get.acme.sh | sh -s email="$EMAIL" +fi + +# acme.sh 3.x 默认 CA 是 ZeroSSL,这里显式固定为 Let's Encrypt +"$ACME" --set-default-ca --server letsencrypt + +echo "==> acme.sh 就绪" +"$ACME" --version + +# 安装脚本会自动写入一条 cron(每天执行 acme.sh --cron),无需再手配续签 +echo "==> 已注册的 cron:" +crontab -l 2>/dev/null | grep acme.sh || echo " (未找到,请手动添加,参考 crontab.example)" diff --git a/acme/issue.sh b/acme/issue.sh new file mode 100644 index 0000000..30cea8f --- /dev/null +++ b/acme/issue.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# 首次申请证书并启用 HTTPS(完整引导)。 +# +# 1. 准备宿主机目录:acme.sh 写挑战文件,nginx 容器 bind mount 只读 +# 2. 启动 nginx —— 此时 ssl.d/ 为空,只加载 80 端口配置,不依赖证书 +# 3. 申请 SAN 证书(webroot 验证,挑战文件由 nginx 直接对外提供) +# 4. 把证书安装到 /etc/letsencrypt/live// +# --reloadcmd 会被 acme.sh 记住,之后每次自动续签都执行 +# 5. 启用 HTTPS server 块并重载 nginx +# +# 需要 root 权限(要写 /etc/letsencrypt 与 /var/www):sudo bash issue.sh +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NGINX_DIR="$SCRIPT_DIR/../nginx" + +EMAIL="${EMAIL:-admin@stonelan.cn}" +CERT_NAME="${CERT_NAME:-stonelan.cn}" +WEBROOT="${WEBROOT:-/var/www/certbot}" +CERT_HOME="${CERT_HOME:-/etc/letsencrypt/live/$CERT_NAME}" +DOMAINS="${DOMAINS:-fastgpt.stonelan.cn gitea.stonelan.cn image.stonelan.cn registry.stonelan.cn www.stonelan.cn}" +ACME="${ACME:-$HOME/.acme.sh/acme.sh}" + +# acme.sh 的 Main_Domain 就是 -d 列表的第一个,--install-cert 要用它 +PRIMARY="${DOMAINS%% *}" + +if [ ! -s "$ACME" ]; then + echo "未找到 acme.sh,请先执行:$SCRIPT_DIR/install.sh" >&2 + exit 1 +fi + +# 1. 目录 +mkdir -p "$WEBROOT/.well-known/acme-challenge" "$CERT_HOME" +chmod 755 "$WEBROOT" "$WEBROOT/.well-known" "$WEBROOT/.well-known/acme-challenge" + +# 2. nginx(无证书也能起来) +echo "==> 启动 nginx" +docker compose -f "$NGINX_DIR/docker-compose.yml" up -d + +# 3. 申请 +args=() +for d in $DOMAINS; do + args+=(-d "$d") +done + +echo "==> 申请证书(主域 $PRIMARY,共 $(echo $DOMAINS | wc -w) 个域名):$DOMAINS" +"$ACME" --issue --webroot "$WEBROOT" --server letsencrypt "${args[@]}" + +# 4. 安装证书 +echo "==> 安装证书到 $CERT_HOME" +"$ACME" --install-cert -d "$PRIMARY" \ + --cert-file "$CERT_HOME/cert.pem" \ + --key-file "$CERT_HOME/privkey.pem" \ + --fullchain-file "$CERT_HOME/fullchain.pem" \ + --reloadcmd "docker kill -s HUP nginx-proxy" + +# 5. 启用 HTTPS +echo "==> 启用 HTTPS 配置" +exec bash "$NGINX_DIR/enable-ssl.sh" diff --git a/acme/renew.sh b/acme/renew.sh new file mode 100644 index 0000000..7d41014 --- /dev/null +++ b/acme/renew.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# +# 手动触发一次续签检查。 +# +# acme.sh 安装时已经自动写入了 cron(每天 ~/.acme.sh/acme.sh --cron), +# 正常情况下不需要手动执行,这个脚本只是方便排查和立即验证。 +# 续签成功时 acme.sh 会自动执行 issue.sh 里登记的 --reloadcmd。 +# +set -euo pipefail + +ACME="${ACME:-$HOME/.acme.sh/acme.sh}" + +if [ ! -s "$ACME" ]; then + echo "未找到 acme.sh,请先执行本目录下的 install.sh" >&2 + exit 1 +fi + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始续签检查" +"$ACME" --cron +echo "[$(date '+%Y-%m-%d %H:%M:%S')] 续签检查完成" diff --git a/certbot/Dockerfile b/certbot/Dockerfile deleted file mode 100644 index 5458e49..0000000 --- a/certbot/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -# Certbot 镜像 - Let's Encrypt 证书管理 -# -# 【重要】官方 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 diff --git a/certbot/README.md b/certbot/README.md deleted file mode 100644 index 7e8c2ee..0000000 --- a/certbot/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# Let's Encrypt 证书管理服务 - -独立的 certbot 服务,管理 SSL 证书的申请与自动续签。 - -## 架构 - -``` -用户请求 ──▶ frps(远程 80/443) ──▶ frpc ──▶ nginx-proxy (80 / 8443) - │ - ACME 验证 ◀── 直接读 certbot-webroot 卷 - HTTPS ◀── 读 certbot-certs 卷(证书) - HTTPS 配置 ◀── 挂载 ./ssl.d(证书就绪后才写入) -``` - -certbot **不再常驻**:它只是一个一次性任务容器,通过 `docker compose run --rm` 执行。 - -## 为什么 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. 首次申请证书(一键引导) - -```bash -cd common-server-public/certbot -chmod +x init-cert.sh renew-cert.sh check-cert.sh ../nginx/enable-ssl.sh -./init-cert.sh -``` - -脚本依次完成:创建共享卷 → 启动 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 -e -# 参考 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 -``` - -- `renew-cert.sh`:续签,然后给 nginx 发 HUP 优雅重载 -- `check-cert.sh`:剩余有效期低于 20 天时以非 0 退出,用于接告警 - -### 3. 手动操作 - -```bash -./renew-cert.sh # 手动续签 -./check-cert.sh # 查看证书与剩余天数 -../nginx/enable-ssl.sh # 单独启用 HTTPS 配置(部分域名没证书时会跳过) -docker compose run --rm certbot certificates -``` - -## 目录与卷 - -| 卷 / 目录 | 容器内路径 | 使用者 | 说明 | -|---|---|---|---| -| `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 块 | - -卷名在 `certbot/docker-compose.yml` 里用 `name:` 固定(compose 默认会加项目名前缀, -两个 compose 项目目录不同,不固定会导致 nginx 侧 `external: true` 找不到卷)。 - -## 端口 - -| 服务 | 容器端口 | 宿主机端口 | 远程端口 | -|---|---|---|---| -| nginx | 80 | 8081 | 80 | -| nginx | 8443 | 8443 | 443 | - -## 故障排查 - -```bash -# nginx 是否只加载了 HTTP 配置(冷启动预期如此) -docker exec nginx-proxy nginx -T | grep -c "listen 8443" - -# 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 -``` - -## 注意事项 - -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 deleted file mode 100644 index bc7cf62..0000000 --- a/certbot/check-cert.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/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 deleted file mode 100644 index cb8bb9d..0000000 --- a/certbot/crontab.example +++ /dev/null @@ -1,10 +0,0 @@ -# Let's Encrypt 自动续签 cron 配置 -# 编辑:crontab -e -# -# 注意:cron 的 PATH 极简,务必用 bash <绝对路径> 调用(脚本内部也会自行补齐 PATH) - -# 续签:LE 官方建议一天两次 -0 2,15 * * * bash /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 deleted file mode 100644 index e06f843..0000000 --- a/certbot/docker-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -services: - certbot: - build: . - 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 - 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: - trim-default: - external: true diff --git a/certbot/init-cert.sh b/certbot/init-cert.sh deleted file mode 100755 index ec426a6..0000000 --- a/certbot/init-cert.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/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 - -export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -NGINX_DIR="$SCRIPT_DIR/../nginx" - -EMAIL="${EMAIL:-admin@stonelan.cn}" -DOMAINS="${DOMAINS:-fastgpt.stonelan.cn gitea.stonelan.cn image.stonelan.cn registry.stonelan.cn www.stonelan.cn}" - -args=() -for d in $DOMAINS; do - args+=(-d "$d") -done - -cd "$SCRIPT_DIR" - -# 1. 共享卷 -for v in certbot-certs certbot-webroot certbot-logs; do - docker volume create "$v" >/dev/null -done - -# 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 \ - --non-interactive \ - --agree-tos \ - --no-eff-email \ - --email "$EMAIL" \ - --keep-until-expiring \ - "${args[@]}" - -# 4. 启用 HTTPS -echo "==> 启用 HTTPS 配置" -exec bash "$NGINX_DIR/enable-ssl.sh" diff --git a/certbot/renew-cert.sh b/certbot/renew-cert.sh deleted file mode 100755 index a7aefa4..0000000 --- a/certbot/renew-cert.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# -# 检查证书续签,并重载 nginx。 -# -# 说明:certbot renew 无论是否真的续签都返回 0,无法用退出码判断。 -# 这里不做判断,统一发 HUP —— nginx 的 HUP 是优雅 reload,代价极低, -# 顺带还能刷新 nginx 对上游的 DNS 缓存。 -# (若要用 --deploy-hook,钩子是在容器内部执行的,就得把 docker.sock -# 挂进容器,等于把宿主机 root 交给容器,不值得。) -# -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)" -cd "$SCRIPT_DIR" - -log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } - -log "开始续签检查" - -docker compose run --rm certbot renew \ - --webroot \ - --webroot-path /var/www/certbot \ - --quiet \ - --no-random-sleep-on-renew - -if docker kill -s HUP nginx-proxy 2>/dev/null; then - log "nginx 已重载" -else - log "警告:nginx-proxy 未运行,跳过重载" >&2 -fi - -log "续签检查完成" diff --git a/nginx/conf.d/00-http.conf b/nginx/conf.d/00-http.conf index 95b50be..2780adb 100644 --- a/nginx/conf.d/00-http.conf +++ b/nginx/conf.d/00-http.conf @@ -17,8 +17,8 @@ server { } # ACME HTTP-01 验证 - # certbot 通过 webroot 写入 /var/www/certbot/.well-known/acme-challenge/ - # 该目录是 certbot-webroot 卷,nginx 以只读方式挂载 + # acme.sh 通过 webroot 写入 /var/www/certbot/.well-known/acme-challenge/ + # 这是宿主机目录,nginx 容器以只读方式 bind mount location /.well-known/acme-challenge/ { root /var/www/certbot; default_type text/plain; diff --git a/nginx/docker-compose.yml b/nginx/docker-compose.yml index 7bd5c8b..819a55a 100644 --- a/nginx/docker-compose.yml +++ b/nginx/docker-compose.yml @@ -6,23 +6,16 @@ services: - "8081:80" - "8443:8443" volumes: - # 证书(只读) - - certbot-certs:/etc/letsencrypt:ro - # ACME 验证目录(只读):certbot 写入挑战文件,nginx 直接对外提供 - - certbot-webroot:/var/www/certbot:ro + # 证书:宿主机 acme.sh 写入,nginx 只读 + - /etc/letsencrypt:/etc/letsencrypt:ro + # ACME HTTP-01 挑战文件:宿主机 acme.sh 写入,nginx 只读 + - /var/www/certbot:/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: external: true diff --git a/nginx/enable-ssl.sh b/nginx/enable-ssl.sh index a59750f..e7e5c02 100644 --- a/nginx/enable-ssl.sh +++ b/nginx/enable-ssl.sh @@ -1,23 +1,29 @@ #!/usr/bin/env bash # -# 启用 HTTPS 配置:只把"已经拿到证书"的域名对应的 server 块 -# 从 ssl.d.available/ 复制到 ssl.d/(nginx 挂载并 include 的目录), -# 然后校验配置并优雅重载 nginx。 +# 启用 HTTPS 配置:证书就绪后,把 ssl.d.available/ 下的 server 块 +# 复制进 ssl.d/(nginx 容器 bind mount 并 include 的目录),校验后优雅重载 nginx。 # # 冷启动时 ssl.d/ 为空,nginx 只加载 conf.d/00-http.conf(不引用证书), -# 因此可以在没有证书的情况下正常启动并提供 80 端口 —— 这是打破 +# 因此可以在没有证书的情况下启动并提供 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" + +CERT_NAME="${CERT_NAME:-stonelan.cn}" +CERT_FULL="/etc/letsencrypt/live/$CERT_NAME/fullchain.pem" DOMAINS="${DOMAINS:-fastgpt.stonelan.cn gitea.stonelan.cn image.stonelan.cn registry.stonelan.cn www.stonelan.cn}" +# 证书在宿主机上,直接判断即可(不再需要进容器) +if [ ! -f "$CERT_FULL" ]; then + echo "证书尚未生成:$CERT_FULL" + echo "请先执行 acme/issue.sh,成功后再运行本脚本" + exit 0 +fi + mkdir -p "$ENABLED" cd "$SCRIPT_DIR" @@ -29,12 +35,6 @@ for d in $DOMAINS; do 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") @@ -43,7 +43,7 @@ for d in $DOMAINS; do done if [ "${#copied[@]}" -eq 0 ]; then - echo "没有需要变更的 HTTPS 配置" + echo "HTTPS 配置已是最新,无需变更" exit 0 fi diff --git a/nginx/ssl.d.available/fastgpt.conf b/nginx/ssl.d.available/fastgpt.conf index 6c2cc5f..df1b517 100644 --- a/nginx/ssl.d.available/fastgpt.conf +++ b/nginx/ssl.d.available/fastgpt.conf @@ -1,12 +1,12 @@ # HTTPS 配置 —— fastgpt.stonelan.cn -# 仅在 /etc/letsencrypt/live/fastgpt.stonelan.cn/fullchain.pem 存在时, -# 由 nginx/enable-ssl.sh 复制到 ssl.d/ 才会被 nginx 加载。 +# 5 个域名共用一张 SAN 证书,统一放在 /etc/letsencrypt/live/stonelan.cn/。 +# 由 nginx/enable-ssl.sh 在证书就绪后复制到 ssl.d/ 才会被 nginx 加载。 server { listen 8443 ssl; server_name fastgpt.stonelan.cn; - ssl_certificate /etc/letsencrypt/live/fastgpt.stonelan.cn/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/fastgpt.stonelan.cn/privkey.pem; + ssl_certificate /etc/letsencrypt/live/stonelan.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/stonelan.cn/privkey.pem; # SSL 优化配置 ssl_protocols TLSv1.2 TLSv1.3; diff --git a/nginx/ssl.d.available/gitea.conf b/nginx/ssl.d.available/gitea.conf index 6a15b01..c2af6c2 100644 --- a/nginx/ssl.d.available/gitea.conf +++ b/nginx/ssl.d.available/gitea.conf @@ -1,12 +1,13 @@ # HTTPS 配置 —— gitea.stonelan.cn -# 仅在证书存在时由 nginx/enable-ssl.sh 复制到 ssl.d/ 才会生效。 +# 5 个域名共用一张 SAN 证书,统一放在 /etc/letsencrypt/live/stonelan.cn/。 +# 由 nginx/enable-ssl.sh 在证书就绪后复制到 ssl.d/ 才会生效。 server { listen 8443 ssl; server_name gitea.stonelan.cn; client_max_body_size 200m; - ssl_certificate /etc/letsencrypt/live/gitea.stonelan.cn/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/gitea.stonelan.cn/privkey.pem; + ssl_certificate /etc/letsencrypt/live/stonelan.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/stonelan.cn/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; diff --git a/nginx/ssl.d.available/image.conf b/nginx/ssl.d.available/image.conf index ee43e3d..73a4686 100644 --- a/nginx/ssl.d.available/image.conf +++ b/nginx/ssl.d.available/image.conf @@ -1,11 +1,12 @@ # HTTPS 配置 —— image.stonelan.cn -# 仅在证书存在时由 nginx/enable-ssl.sh 复制到 ssl.d/ 才会生效。 +# 5 个域名共用一张 SAN 证书,统一放在 /etc/letsencrypt/live/stonelan.cn/。 +# 由 nginx/enable-ssl.sh 在证书就绪后复制到 ssl.d/ 才会生效。 server { listen 8443 ssl; server_name image.stonelan.cn; - ssl_certificate /etc/letsencrypt/live/image.stonelan.cn/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/image.stonelan.cn/privkey.pem; + ssl_certificate /etc/letsencrypt/live/stonelan.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/stonelan.cn/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; diff --git a/nginx/ssl.d.available/registry.conf b/nginx/ssl.d.available/registry.conf index 96190c9..1b8fd20 100644 --- a/nginx/ssl.d.available/registry.conf +++ b/nginx/ssl.d.available/registry.conf @@ -1,12 +1,13 @@ # HTTPS 配置 —— registry.stonelan.cn -# 仅在证书存在时由 nginx/enable-ssl.sh 复制到 ssl.d/ 才会生效。 +# 5 个域名共用一张 SAN 证书,统一放在 /etc/letsencrypt/live/stonelan.cn/。 +# 由 nginx/enable-ssl.sh 在证书就绪后复制到 ssl.d/ 才会生效。 server { listen 8443 ssl; server_name registry.stonelan.cn; client_max_body_size 500m; - ssl_certificate /etc/letsencrypt/live/registry.stonelan.cn/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/registry.stonelan.cn/privkey.pem; + ssl_certificate /etc/letsencrypt/live/stonelan.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/stonelan.cn/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; diff --git a/nginx/ssl.d.available/www.conf b/nginx/ssl.d.available/www.conf index 14f17fd..133c776 100644 --- a/nginx/ssl.d.available/www.conf +++ b/nginx/ssl.d.available/www.conf @@ -1,11 +1,12 @@ # HTTPS 配置 —— www.stonelan.cn -# 仅在证书存在时由 nginx/enable-ssl.sh 复制到 ssl.d/ 才会生效。 +# 5 个域名共用一张 SAN 证书,统一放在 /etc/letsencrypt/live/stonelan.cn/。 +# 由 nginx/enable-ssl.sh 在证书就绪后复制到 ssl.d/ 才会生效。 server { listen 8443 ssl; server_name www.stonelan.cn; - ssl_certificate /etc/letsencrypt/live/www.stonelan.cn/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/www.stonelan.cn/privkey.pem; + ssl_certificate /etc/letsencrypt/live/stonelan.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/stonelan.cn/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; diff --git a/nginx/updateConfig.sh b/nginx/updateConfig.sh index 789ca42..9795f98 100644 --- a/nginx/updateConfig.sh +++ b/nginx/updateConfig.sh @@ -24,19 +24,25 @@ for conf in conf.d/*.conf; do sudo cp "$conf" /etc/nginx/conf.d/ done -# HTTPS 配置:只有证书已就绪的域名才启用,避免 nginx 因证书缺失拒绝启动 +# HTTPS 配置:证书就绪后才启用,避免 nginx 因证书缺失拒绝启动 +# 5 个域名共用一张 SAN 证书,统一放在 CERT_NAME 目录下 +CERT_NAME="${CERT_NAME:-stonelan.cn}" +CERT_FULL="/etc/letsencrypt/live/$CERT_NAME/fullchain.pem" 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 [ -f "$CERT_FULL" ]; then + for d in $DOMAINS; do + src="ssl.d.available/$d.conf" + if [ -f "$src" ]; then + sudo cp "$src" "/etc/nginx/ssl.d/$d.conf" + echo "已启用 $d" + else + echo "跳过 $d:缺少 $src" + fi + done +else + echo "证书尚未生成($CERT_FULL),跳过 HTTPS 配置" +fi # 测试并重载配置 if sudo nginx -t; then