方案C:分发层容器化(edge nginx)+ Hy2 + DNS-01 续期
deploy-pangolin / deploy (push) Has been cancelled

- edge: 容器化 nginx 接管 80/443,逐字移植现网 vhost(blog/jiu/marzban/pay)+ stream SNI 分流
- singbox: Hysteria2(host 网络,UDP 443)
- certbot: DNS-01(Cloudflare)续期,容器化
- xray: REALITY 仅 profile newnode(本台沿用 Marzban)
- deploy.sh 幂等且切换感知;cutover.sh 一次性停宿主 nginx 切容器(可秒级回滚)
- 弃用方案A 的 host nginx-apply/sudoers/root-setup
- 部署目录改 ~/pangolin(免 root);CI 注入 CF_API_TOKEN

EC2 实测:edge nginx -t 通过(真实证书)、compose 校验通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-05-30 16:42:30 +08:00
parent bc77e85b7d
commit 1a346c32c1
20 changed files with 430 additions and 277 deletions
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# cutover.sh —— 一次性切换:宿主 nginx → pangolin-edge 容器(需 root 一次)。
# sudo bash /home/ec2-user/pangolin/scripts/cutover.sh
# 选空闲时段执行;代理(Marzban REALITY)会有秒级重连,Shadowrocket 自动恢复。
# 失败可秒级回滚:docker-compose -p pangolin stop edge && systemctl start nginx
set -euo pipefail
if [[ "$(id -u)" -ne 0 ]]; then
echo "请以 root 运行:sudo bash $0" >&2
exit 1
fi
cd "$(dirname "$0")/.." # -> deploy/
EDGE_IMAGE="nginx:1.27-alpine"
echo "==> [1/4] 切换前校验 edge 配置(含证书路径)"
docker run --rm \
-v "$PWD/edge/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "$PWD/edge/stream.conf:/etc/nginx/stream.conf:ro" \
-v "$PWD/edge/conf.d:/etc/nginx/conf.d:ro" \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
-v /opt/jiu:/opt/jiu:ro \
-v /var/www/pay:/var/www/pay:ro \
"$EDGE_IMAGE" nginx -t
echo "==> [2/4] 停用宿主 nginx 并起 edge 容器(交接窗口,秒级)"
systemctl stop nginx
systemctl disable nginx 2>/dev/null || true
docker-compose -p pangolin up -d edge singbox
echo "==> [3/4] 等待并健康检查"
sleep 2
fail=0
# REALITY 经 443 → 10443(Marzban),SNI=www.cloudflare.com 应握手成功
if echo | timeout 10 openssl s_client -connect 127.0.0.1:443 -servername www.cloudflare.com >/dev/null 2>&1; then
echo " [REALITY] 443 握手 OK"; else echo " [REALITY] 握手失败"; fail=1; fi
# 业务 vhost(经 stream→本机 8443/8444/8445)
for d in blog jiu vpn; do
code="$(curl -ks -o /dev/null -w '%{http_code}' --resolve ${d}.51yanmei.com:443:127.0.0.1 https://${d}.51yanmei.com/ 2>/dev/null || echo 000)"
echo " [${d}] https -> ${code}"
[[ "$code" =~ ^[2-4][0-9][0-9]$ ]] || fail=1
done
echo "==> [4/4] 结果"
if [[ "$fail" == "0" ]]; then
echo " 切换成功 ✅(宿主 nginx 已停,配置保留可回滚)"
else
echo " ❌ 检查未全通过。回滚:docker-compose -p pangolin stop edge && systemctl start nginx" >&2
exit 1
fi
+32 -47
View File
@@ -1,61 +1,46 @@
#!/usr/bin/env bash
# deploy.sh —— 在 EC2 上幂等编排 pangolin 数据面
# 由 CI(NAS runner via ssh)调用,也可手动 `ssh ec2 'cd /opt/pangolin && ./scripts/deploy.sh'`。
#
# 步骤:生成密钥 -> 渲染配置 -> docker-compose up -> (幂等)加 nginx 路由 -> 健康检查 -> 输出客户端信息
# 全程不触碰 marzban/blog/jiu;唯一特权动作是 sudo 调用受限的 nginx-apply 脚本
# deploy.sh —— 幂等部署(免 root)。CI 与手动均可
# - 总是确保 singbox(Hy2)在跑;
# - edge:若宿主 nginx 仍在(未切换)则只校验配置、不启动(避免抢 80/443);
# 宿主 nginx 已停(已切换)则正常拉起。
# 首次 nginx 切换由 cutover.sh(需 root 一次)完成
set -euo pipefail
cd "$(dirname "$0")/.." # -> deploy/
COMPOSE="docker-compose -p pangolin"
NGINX_APPLY="/usr/local/sbin/pangolin-nginx-apply.sh"
EDGE_IMAGE="nginx:1.27-alpine"
echo "==> [1/5] 生成/复用密钥与证书"
echo "==> [1/4] 生成/复用密钥与证书"
./scripts/gen-secrets.sh
echo "==> [2/5] 渲染配置(模板 -> config.json)"
echo "==> [2/4] 渲染配置"
# shellcheck disable=SC1091
source ./secrets/pangolin.env
render() { # render <tmpl> <out>
sed -e "s|__UUID__|${UUID}|g" \
-e "s|__REALITY_PRIVATE_KEY__|${REALITY_PRIVATE_KEY}|g" \
-e "s|__REALITY_SHORT_ID__|${REALITY_SHORT_ID}|g" \
-e "s|__HY2_PASSWORD__|${HY2_PASSWORD}|g" \
"$1" > "$2"
sed "s|__HY2_PASSWORD__|${HY2_PASSWORD}|g" ./singbox/config.tmpl.json > ./singbox/config.json
echo "==> [3/4] 拉起 singbox(Hy2)"
docker-compose -p pangolin up -d singbox
docker-compose -p pangolin ps singbox
echo "==> [4/4] edge(分发层)"
edge_validate() {
docker run --rm \
-v "$PWD/edge/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "$PWD/edge/stream.conf:/etc/nginx/stream.conf:ro" \
-v "$PWD/edge/conf.d:/etc/nginx/conf.d:ro" \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
-v /opt/jiu:/opt/jiu:ro \
-v /var/www/pay:/var/www/pay:ro \
"$EDGE_IMAGE" nginx -t
}
render ./xray/config.tmpl.json ./xray/config.json
render ./singbox/config.tmpl.json ./singbox/config.json
echo "==> [3/5] 拉起容器(docker-compose up -d)"
$COMPOSE pull --quiet || true
$COMPOSE up -d
$COMPOSE ps
echo "==> [4/5] nginx SNI 路由(幂等)"
if grep -qF "127.0.0.1:11443" /etc/nginx/stream.conf 2>/dev/null; then
echo " 路由已存在,跳过"
elif [[ -x "$NGINX_APPLY" ]]; then
sudo "$NGINX_APPLY"
if systemctl is-active --quiet nginx 2>/dev/null; then
echo " 宿主 nginx 仍在运行 → 仅校验 edge 配置,不启动(切换请跑 cutover.sh)"
edge_validate && echo " edge 配置校验通过 ✅"
else
echo " !! 未找到 $NGINX_APPLY(需先执行一次性 root 前置 setup/root-setup.sh)" >&2
exit 1
fi
echo "==> [5/5] 健康检查"
ok=1
# REALITY:经宿主 nginx 用 www.apple.com 做 TLS 握手应成功
if echo | timeout 10 openssl s_client -connect 127.0.0.1:443 -servername www.apple.com >/dev/null 2>&1; then
echo " [REALITY] 443/www.apple.com 握手 OK"
else
echo " [REALITY] 握手失败"; ok=0
fi
# Hy2:UDP 443 应有 sing-box 在监听
if ss -uln 2>/dev/null | grep -q ":443 "; then
echo " [Hy2] UDP 443 监听 OK"
else
echo " [Hy2] UDP 443 未监听"; ok=0
echo " 宿主 nginx 未运行 → 拉起 edge"
edge_validate
docker-compose -p pangolin up -d edge
docker-compose -p pangolin ps edge
fi
./scripts/print-clients.sh
[[ "$ok" == "1" ]] && echo "==> 部署完成 ✅" || { echo "==> 健康检查未通过 ❌"; exit 1; }
echo "==> 完成 ✅"
+17 -25
View File
@@ -1,53 +1,45 @@
#!/usr/bin/env bash
# gen-secrets.sh —— 首次在 EC2 生成并持久化密钥/证书(幂等)。
# 产物存于 ./secrets/(700,gitignore,绝不进仓库/CI 日志)。
# - pangolin.env : UUID / REALITY 公私钥 / shortId / Hy2 口令
# - hy2/hy2.crt, hy2/hy2.key : Hysteria2 自签证书(ECDSA)
# gen-secrets.sh —— 幂等生成本台所需密钥/证书,持久化到 ./secrets/(700,gitignore)。
# - pangolin.env : HY2_PASSWORD
# - hy2/hy2.crt,hy2.key: Hysteria2 自签证书(ECDSA)
# - cloudflare.ini : 由 $CF_API_TOKEN 渲染(证书续期用),600
set -euo pipefail
cd "$(dirname "$0")/.." # -> deploy/
SECRETS_DIR="./secrets"
ENV_FILE="${SECRETS_DIR}/pangolin.env"
HY2_DIR="${SECRETS_DIR}/hy2"
XRAY_IMAGE="ghcr.io/xtls/xray-core:26.3.27"
MASQ_CN="www.bing.com"
mkdir -p "$HY2_DIR"
chmod 700 "$SECRETS_DIR"
# Hy2 口令
if [[ -f "$ENV_FILE" ]]; then
echo "[gen-secrets] $ENV_FILE 已存在,跳过密钥生成(幂等)"
echo "[gen-secrets] $ENV_FILE 已存在,跳过"
else
echo "[gen-secrets] 生成 REALITY 密钥对 / UUID / shortId / Hy2 口令 ..."
# REALITY x25519 密钥对(从 xray 输出解析 Private/Public)
KP="$(docker run --rm "$XRAY_IMAGE" x25519)"
REALITY_PRIVATE_KEY="$(printf '%s\n' "$KP" | awk -F': ' '/Private/{print $2}')"
REALITY_PUBLIC_KEY="$(printf '%s\n' "$KP" | awk -F': ' '/Public/{print $2}')"
UUID="$(cat /proc/sys/kernel/random/uuid)"
REALITY_SHORT_ID="$(openssl rand -hex 8)"
HY2_PASSWORD="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-24)"
umask 177
cat > "$ENV_FILE" <<EOF
# pangolin Phase-0 secrets —— 切勿提交/外泄
UUID=${UUID}
REALITY_PRIVATE_KEY=${REALITY_PRIVATE_KEY}
REALITY_PUBLIC_KEY=${REALITY_PUBLIC_KEY}
REALITY_SHORT_ID=${REALITY_SHORT_ID}
HY2_PASSWORD=${HY2_PASSWORD}
EOF
printf 'HY2_PASSWORD=%s\n' "$HY2_PASSWORD" > "$ENV_FILE"
echo "[gen-secrets] 已写入 $ENV_FILE"
fi
# Hysteria2 自签证书(幂等)
# Hy2 自签证书
if [[ -f "${HY2_DIR}/hy2.crt" && -f "${HY2_DIR}/hy2.key" ]]; then
echo "[gen-secrets] Hy2 证书已存在,跳过"
else
echo "[gen-secrets] 生成 Hy2 自签证书 (CN=${MASQ_CN}) ..."
openssl ecparam -genkey -name prime256v1 -out "${HY2_DIR}/hy2.key"
openssl req -new -x509 -days 3650 -key "${HY2_DIR}/hy2.key" \
-out "${HY2_DIR}/hy2.crt" -subj "/CN=${MASQ_CN}"
# sing-box 容器内以 root 运行,证书需可读
chmod 644 "${HY2_DIR}/hy2.crt" "${HY2_DIR}/hy2.key"
echo "[gen-secrets] 已写入 Hy2 证书"
fi
# Cloudflare 续期凭据(有 token 才渲染)
if [[ -n "${CF_API_TOKEN:-}" ]]; then
umask 177
sed "s|__CF_API_TOKEN__|${CF_API_TOKEN}|g" ./certbot/cloudflare.ini.tmpl > "${SECRETS_DIR}/cloudflare.ini"
echo "[gen-secrets] 已渲染 cloudflare.ini"
else
echo "[gen-secrets] 未设置 CF_API_TOKEN,跳过 cloudflare.ini(续期前需补)"
fi
+8 -18
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env bash
# print-clients.sh —— 生成两条客户端导入串,写 ./secrets/clients.txt。
# 注意:含口令/密钥,【不打印到 stdout】以免进 CI 日志;手动 `ssh ec2 cat` 取用
# print-clients.sh —— 生成 pangolin 自有线路(本台仅 Hy2)的导入串,写 ./secrets/clients.txt。
# 含口令,不打印到 stdout(避免进 CI 日志)。REALITY 仍由 Marzban 提供,不在此列
set -euo pipefail
cd "$(dirname "$0")/.." # -> deploy/
# shellcheck disable=SC1091
source ./secrets/pangolin.env
# 公网 IP:优先 IMDSv2,可用 PANGOLIN_PUBLIC_IP 覆盖
HOST="${PANGOLIN_PUBLIC_IP:-}"
if [[ -z "$HOST" ]]; then
TOKEN="$(curl -s -X PUT 'http://169.254.169.254/latest/api/token' -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' || true)"
@@ -15,27 +14,18 @@ if [[ -z "$HOST" ]]; then
fi
[[ -z "$HOST" ]] && HOST="YOUR_SERVER_IP"
REALITY_LINK="vless://${UUID}@${HOST}:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=${REALITY_PUBLIC_KEY}&sid=${REALITY_SHORT_ID}&type=tcp#pangolin-reality"
HY2_LINK="hysteria2://${HY2_PASSWORD}@${HOST}:443?insecure=1&sni=www.bing.com#pangolin-hy2"
OUT="./secrets/clients.txt"
umask 177
cat > "$OUT" <<EOF
# pangolin Phase-0 客户端导入信息 (生成于 $(date -u +%FT%TZ))
# 含敏感信息,勿外泄。
# pangolin 自有线路导入信息(生成于 $(date -u +%FT%TZ))。含敏感信息,勿外泄。
[REALITY / VLESS+Vision] —— 平时主线
${REALITY_LINK}
[Hysteria2] —— 晚高峰加速线(自签证书,客户端需允许不安全/insecure)
[Hysteria2] —— 晚高峰加速线(自签证书,客户端需允许 insecure)
${HY2_LINK}
字段速查:
server = ${HOST}
REALITY sni = www.apple.com port=443/tcp flow=xtls-rprx-vision
REALITY pbk = ${REALITY_PUBLIC_KEY}
REALITY sid = ${REALITY_SHORT_ID}
uuid = ${UUID}
Hy2 sni = www.bing.com port=443/udp insecure=1
server = ${HOST} port = 443/udp sni = www.bing.com insecure = 1
# 注:REALITY 主线仍由 Marzban 管理(SNI=www.cloudflare.com),不在此文件。
EOF
echo "[print-clients] 已写入 ${OUT}(用 'ssh ec2 cat /opt/pangolin/secrets/clients.txt' 取用)"
echo "[print-clients] 已写入 ${OUT}(ssh ec2 cat /opt/pangolin/secrets/clients.txt 取用)"