diff --git a/README.md b/README.md index be4743b..c7ec3d5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ ## 脚本结构 - `init-certs.sh`:首次初始化入口,内部调用 `scripts/init-certs-core.sh`。 +- `add-domain-certs.sh`:在 OpenResty 已运行时追加新域名证书,内部调用 `scripts/add-domain-certs-core.sh`。 +- `reload.sh`:修改 nginx 配置后校验并重载 OpenResty。 - `uninstall.sh`:卸载入口,目前用于卸载证书自动续期 cron。 - `scripts/`:放具体实现脚本,包括 nginx 域名模板生成、证书初始化核心逻辑、手动续期、安装和卸载续期 cron。 @@ -65,6 +67,48 @@ sh init-certs.sh --skip-renew-cron 重复执行 `init-certs.sh` 不会强制重签已有证书;Certbot 会保留还没到期的证书。 重复执行时也不会重复添加 cron;安装脚本会先删除旧任务块,再写入新任务。 +## 追加域名证书 + +当 OpenResty 已经在线运行,只需要给新域名补证书时,使用 `add-domain-certs.sh`。 + +先把要新增的域名写入 `add-domain-certs.sh` 的 `DOMAINS`,这里只放本次要新增的域名: + +```sh +DOMAINS=" +proxy.sggai.site +gitea.sggai.site +" +``` + +然后执行: + +```bash +sh add-domain-certs.sh +``` + +这个脚本会: + +1. 校验 Docker Compose 配置,并确认 `openresty` 容器已经在运行。 +2. 检查每个新增域名是否已有 nginx 配置。 +3. 如果缺少 `conf/conf.d/.conf`,自动调用 `scripts/ensure-domain-conf.sh` 生成基础模板。 +4. 为缺失证书的新域名生成临时 dummy 证书。 +5. 校验并重载 OpenResty,让新增配置和 dummy 证书生效。 +6. 检查本机和公网 HTTP-01 challenge 路径是否可访问。 +7. 删除本次创建的 dummy 证书,调用 Certbot 申请正式证书。 +8. 再次重载 OpenResty,让正式证书生效。 + +如果服务器无法从本机访问自己的公网域名,但外部访问是正常的,可以跳过公网探测: + +```bash +sh add-domain-certs.sh --skip-public-http-check +``` + +自动生成的 nginx 配置是静态站点基础模板,只保证证书申请和 HTTPS 站点能启动。如果新域名需要反向代理到后端服务,请按业务需要修改 `conf/conf.d/.conf`,再执行: + +```bash +sh reload.sh +``` + ## 日常启动和停止 启动: @@ -182,6 +226,7 @@ http://10.1.0.1:3001 ## 注意事项 - `certs/`、`logs/`、运行时 webroot 文件默认不提交到 Git。 +- `conf/conf.d/00-default-deny.conf` 是默认拒绝站点,用于丢弃没有匹配到具体 `server_name` 的 HTTP 请求,并拒绝未知 SNI 的 HTTPS 握手。 - 如果证书文件不存在,OpenResty 的 HTTPS 配置会启动失败;首次部署请先运行 `init-certs.sh`。 - `ai.sggai.site` 当前通过 `000-` 前缀配置文件覆盖原配置,`openresty -t` 可能出现同名 server 被忽略的 warning。 - 如果需要透传带下划线的请求头,例如 `Session_id`,需要确认 Nginx 的 `underscores_in_headers` 策略是否符合预期。 diff --git a/add-domain-certs.sh b/add-domain-certs.sh new file mode 100644 index 0000000..e0205d3 --- /dev/null +++ b/add-domain-certs.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +# Domains to add to an already running gateway. Put only new domains here, +# one per line. +DOMAINS=" +proxy.sggai.site +gitea.sggai.site +" + +# Let's Encrypt registration email. +CERT_EMAIL="243823965@qq.com" + +export DOMAINS CERT_EMAIL + +sh "$SCRIPT_DIR/scripts/add-domain-certs-core.sh" "$@" diff --git a/conf/conf.d/00-default-deny.conf b/conf/conf.d/00-default-deny.conf new file mode 100644 index 0000000..3f95523 --- /dev/null +++ b/conf/conf.d/00-default-deny.conf @@ -0,0 +1,11 @@ +server { + listen 80 default_server; + server_name _; + return 444; +} + +server { + listen 443 ssl default_server; + server_name _; + ssl_reject_handshake on; +} diff --git a/scripts/add-domain-certs-core.sh b/scripts/add-domain-certs-core.sh new file mode 100644 index 0000000..af74dae --- /dev/null +++ b/scripts/add-domain-certs-core.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env sh +set -eu + +# Add certificates for new domains on an already running OpenResty gateway. +# This flow is separate from init-certs-core.sh and never restarts containers. + +CERT_ROOT="./certs/live" +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +CONF_DIR="${CONF_DIR:-./conf/conf.d}" +SKIP_PUBLIC_HTTP_CHECK="${SKIP_PUBLIC_HTTP_CHECK:-0}" +DUMMY_DOMAINS="" +HTTP_PROBE_DIR="./www/.well-known/acme-challenge" +HTTP_PROBE_TOKEN="add-domain-certs-probe-$$" +HTTP_PROBE_PATH=".well-known/acme-challenge/$HTTP_PROBE_TOKEN" +HTTP_PROBE_VALUE="openresty-gateway-add-domain-probe-$$" + +cd "$ROOT_DIR" + +usage() { + cat </dev/null 2>&1; then + echo "Warning: curl is missing; skipping HTTP-01 reachability probe." >&2 + return 0 + fi + + mkdir -p "$HTTP_PROBE_DIR" + printf '%s' "$HTTP_PROBE_VALUE" > "$HTTP_PROBE_DIR/$HTTP_PROBE_TOKEN" + trap cleanup_http_probe EXIT HUP INT TERM + + for domain in $DOMAINS; do + url="http://$domain/$HTTP_PROBE_PATH" + + echo "Checking local HTTP-01 route for $domain..." + if ! body="$(curl -fsS --max-time 5 --resolve "$domain:80:127.0.0.1" "$url" 2>&1)"; then + echo "Error: OpenResty is not serving the challenge path for $domain on local port 80." >&2 + echo "Tried: $url via 127.0.0.1" >&2 + echo "$body" >&2 + return 1 + fi + + if [ "$body" != "$HTTP_PROBE_VALUE" ]; then + echo "Error: local challenge response mismatch for $domain." >&2 + echo "Expected: $HTTP_PROBE_VALUE" >&2 + echo "Got: $body" >&2 + return 1 + fi + + if is_enabled "$SKIP_PUBLIC_HTTP_CHECK"; then + echo "Skipping public HTTP-01 route check for $domain." + continue + fi + + echo "Checking public HTTP-01 route for $domain..." + if ! body="$(curl -fsS --max-time 10 "$url" 2>&1)"; then + echo "Error: $domain is not reachable on public HTTP port 80." >&2 + echo "Tried: $url" >&2 + echo "$body" >&2 + echo "If this host cannot hairpin to its public IP but external clients can, rerun with SKIP_PUBLIC_HTTP_CHECK=1." >&2 + return 1 + fi + + if [ "$body" != "$HTTP_PROBE_VALUE" ]; then + echo "Error: public challenge response mismatch for $domain." >&2 + echo "Expected: $HTTP_PROBE_VALUE" >&2 + echo "Got: $body" >&2 + return 1 + fi + done +} + +reload_openresty() { + echo "Validating OpenResty configuration..." + compose exec -T openresty openresty -t + + echo "Reloading OpenResty without restart..." + compose exec -T openresty openresty -s reload +} + +remove_dummy_certs() { + 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 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --skip-public-http-check) + SKIP_PUBLIC_HTTP_CHECK=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +domain_count=0 +for domain in $DOMAINS; do + domain_count=$((domain_count + 1)) +done + +if [ "$domain_count" -eq 0 ]; then + echo "Error: DOMAINS is required. Set it in add-domain-certs.sh." >&2 + exit 1 +fi + +if [ -z "${CERT_EMAIL:-}" ]; then + echo "Error: CERT_EMAIL is required. Set it in add-domain-certs.sh." >&2 + exit 1 +fi + +. "$ROOT_DIR/scripts/lib-compose.sh" + +echo "Validating Docker Compose configuration..." +compose config >/dev/null + +echo "Checking that OpenResty is already running..." +compose exec -T openresty openresty -v >/dev/null + +conf_error=0 +for domain in $DOMAINS; do + if ! conf_file="$(find_domain_conf "$domain")"; then + CONF_DIR="$CONF_DIR" sh "$ROOT_DIR/scripts/ensure-domain-conf.sh" "$domain" + conf_file="$CONF_DIR/$domain.conf" + fi + + if ! grep -Fq "/.well-known/acme-challenge/" "$conf_file"; then + echo "Error: nginx config lacks ACME challenge location: $conf_file" >&2 + conf_error=1 + fi +done + +if [ "$conf_error" -ne 0 ]; then + exit 1 +fi + +export DOMAINS CERT_ROOT +DUMMY_DOMAINS="$(sh "$ROOT_DIR/scripts/ensure-dummy-certs.sh")" + +reload_openresty +check_http_challenge + +if [ -n "$DUMMY_DOMAINS" ]; then + echo "Removing dummy certificate files before requesting real certificates..." + remove_dummy_certs +fi + +if ! sh "$ROOT_DIR/scripts/request-certs.sh"; then + echo "Certificate request failed; restoring dummy certificates." >&2 + sh "$ROOT_DIR/scripts/ensure-dummy-certs.sh" >/dev/null || true + reload_openresty >/dev/null 2>&1 || true + exit 1 +fi + +reload_openresty + +echo "Done."