diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 663fd05..e9afe24 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,6 +1,5 @@ name: deploy-pangolin -# 触发:改 deploy/** 或本 workflow 时自动;也可在 Gitea Actions 页手动触发。 on: push: branches: [main] @@ -11,39 +10,37 @@ on: jobs: deploy: - # ⚠️ 改成你 NAS 上 act_runner 的实际 label - # (Gitea → 仓库/组织 Settings → Actions → Runners 里可见)。 - # 常见为 self-hosted / nas / ubuntu-latest。 + # ⚠️ 改成你 NAS 上 act_runner 的实际 label(Gitea→Settings→Actions→Runners) runs-on: nas steps: - name: Checkout uses: actions/checkout@v4 - - name: Ensure ssh/rsync 存在 + - name: Ensure ssh/rsync run: | if ! command -v rsync >/dev/null || ! command -v ssh >/dev/null; then (command -v apt-get >/dev/null && apt-get update -y && apt-get install -y openssh-client rsync) \ || (command -v apk >/dev/null && apk add --no-cache openssh-client rsync) fi - - name: 准备 SSH 私钥(指向 EC2) + - name: SSH 私钥(指向 EC2) run: | mkdir -p ~/.ssh && chmod 700 ~/.ssh printf '%s\n' "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/ec2_key chmod 600 ~/.ssh/ec2_key ssh-keyscan -H "${{ secrets.EC2_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true - - name: 同步 deploy/ 到 EC2(保留 secrets,不删目标) + - name: 同步 deploy/ 到 EC2(保留 secrets) run: | rsync -az \ --exclude 'secrets' \ - --exclude 'xray/config.json' \ --exclude 'singbox/config.json' \ + --exclude 'xray/config.json' \ -e "ssh -i ~/.ssh/ec2_key -o StrictHostKeyChecking=accept-new" \ - deploy/ "${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:/opt/pangolin/" + deploy/ "${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:pangolin/" - - name: 远程部署(幂等) + - name: 远程部署(免 root,幂等) run: | ssh -i ~/.ssh/ec2_key -o StrictHostKeyChecking=accept-new \ "${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}" \ - 'cd /opt/pangolin && chmod +x scripts/*.sh nginx/*.sh setup/*.sh && ./scripts/deploy.sh' + "export CF_API_TOKEN='${{ secrets.CF_API_TOKEN }}'; cd ~/pangolin && chmod +x scripts/*.sh && ./scripts/deploy.sh" diff --git a/deploy/.env.example b/deploy/.env.example index da6d84a..2473095 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,8 +1,8 @@ -# 可选环境变量(deploy.sh / print-clients.sh 读取)。 -# 真正的密钥由 gen-secrets.sh 自动生成到 deploy/secrets/pangolin.env,无需手填。 +# 可选环境变量。真正的密钥由 gen-secrets.sh 自动生成到 deploy/secrets/。 + +# Cloudflare API Token(证书 DNS-01 续期用);CI 由 Gitea Secret 注入, +# 手动部署时本机 ~/.env 已有 CF_API_TOKEN。 +# CF_API_TOKEN=xxxx # 覆盖客户端导入串里的服务器地址(默认自动取 EC2 公网 IP) # PANGOLIN_PUBLIC_IP=18.136.60.128 - -# root-setup.sh 授权的部署用户(默认 ec2-user) -# PANGOLIN_DEPLOY_USER=ec2-user diff --git a/deploy/README.md b/deploy/README.md index c4ed659..0d33c16 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,50 +1,59 @@ -# Pangolin 部署(Phase 0) +# Pangolin 部署(方案 C:分发层容器化) -容器化数据面:**Xray VLESS+REALITY** + **sing-box Hysteria2**,经 CI/CD 自动部署到节点。 -与现网 marzban/blog/jiu 完全隔离。设计详见 [`../docs/技术方案.md`](../docs/技术方案.md) 与 [`../plan/`](../plan/)。 +把流量分发层(nginx)迁入 Docker,新增 Hysteria2,经 Gitea Actions 自动部署。 +与现网 marzban/jiu/billing/blog 并存。设计见 [`../docs/技术方案.md`](../docs/技术方案.md) 与 +计划 `~/.claude/plans/ci-cd-1-shimmering-panda.md`。 -## 端口与隔离 +## 容器一览(部署后) -| 服务 | 监听 | 经由 | 说明 | -|------|------|------|------| -| Xray REALITY | 容器内 `0.0.0.0:11443` → 宿主 `127.0.0.1:11443` | 宿主 nginx `ssl_preread`(SNI=`www.apple.com`)| 复用 443/TCP,不占公网端口 | -| sing-box Hy2 | 公网 `0.0.0.0:443/udp` | 直接暴露 | 与 TCP 443 不同协议栈,互不冲突 | +| 容器 | 用途 | 端口/网络 | +|------|------|-----------| +| `pangolin-edge`(nginx) | 分发层,接管 80/443,移植现网全部路由 | host 网络 | +| `pangolin-singbox` | Hysteria2 加速线 | host 网络,443/udp | +| `pangolin-certbot` | DNS-01(Cloudflare)证书续期,按需运行 | 无端口 | +| `pangolin-xray`(本台不启用) | REALITY,留给新节点 | profile `newnode` | -现网 Marzban xray 在 `10443`、各站在 `8443-8445`,均不受影响。 +现网保留不动:`marzban`(线上 REALITY+面板)、`jiu_mysql`、`blog-*`,宿主 `jiu.service`/`billing.service`。 -## 自动部署链路 +## 部署目录 +`~/pangolin`(即 `/home/ec2-user/pangolin`,ec2-user 所有,免 root)。 + +## 自动部署 + +`push deploy/**` → NAS runner → `ssh ec2` → `rsync deploy/ → ~/pangolin/` → `deploy.sh`(免 root,幂等)。 +`deploy.sh` 总会拉起 singbox;edge 仅在宿主 nginx 已停时才启动(否则只校验配置)。 + +### Gitea Secrets(仓库 Settings→Actions→Secrets) +`EC2_SSH_KEY`、`EC2_HOST=18.136.60.128`、`EC2_USER=ec2-user`、`CF_API_TOKEN`。 +并把 `.gitea/workflows/deploy.yml` 的 `runs-on` 改成你 runner 的 label。 + +## 一次性切换(nginx 宿主 → 容器,需 root 一次) + +空闲时段执行(代理会秒级重连,Shadowrocket 自动恢复): +```bash +sudo bash /home/ec2-user/pangolin/scripts/cutover.sh ``` -本地改 deploy/** → push gitea(NAS) → NAS act_runner → ssh ec2 → docker-compose up + nginx 增量 reload +失败秒级回滚: +```bash +docker-compose -p pangolin stop edge && sudo systemctl start nginx ``` -## 一次性前置(首次,人工) - -1. **安全组开 UDP 443**(本地 aws):给 `sg-040cf4cd11bdf4d5b` 加 `udp/443 0.0.0.0/0`。 -2. **root 前置**(EC2 上一次,需 root): - ```bash - sudo bash /opt/pangolin/setup/root-setup.sh - ``` - 安装受限特权脚本 `pangolin-nginx-apply.sh` + sudoers,使 CI 能非交互更新 nginx 路由(且拿不到通用 root)。 -3. **Gitea Actions Secrets**(仓库 Settings → Actions → Secrets): - - `EC2_SSH_KEY` = ec2-user 私钥(可登录 18.136.60.128) - - `EC2_HOST` = `18.136.60.128` - - `EC2_USER` = `ec2-user` -4. **确认 runner label**,据此改 `.gitea/workflows/deploy.yml` 的 `runs-on`。 - -## 手动部署(不走 CI) +## 证书续期(切换后) ```bash -rsync -az --exclude secrets deploy/ ec2-user@18.136.60.128:/opt/pangolin/ -ssh ec2 'cd /opt/pangolin && ./scripts/deploy.sh' +# 一次性:把 blog/jiu/vpn 三证书改为 DNS-01(Cloudflare) +cd ~/pangolin +docker-compose -p pangolin run --rm certbot certonly --dns-cloudflare \ + --dns-cloudflare-credentials /etc/cloudflare.ini \ + -d blog.51yanmei.com -d jiu.51yanmei.com -d vpn.51yanmei.com --keep-until-expiring +# 续期 + 重载(建议加进宿主 cron,每日一次) +docker-compose -p pangolin run --rm certbot renew -q +docker exec pangolin-edge nginx -s reload ``` -## 取客户端导入信息(含密钥,不进日志) +## 取客户端导入串(Hy2,含口令) ```bash -ssh ec2 'cat /opt/pangolin/secrets/clients.txt' +ssh ec2 'cat ~/pangolin/secrets/clients.txt' ``` - -## 幂等性 - -`deploy.sh` 可重复执行:密钥已存在则复用、nginx 路由已存在则跳过、compose 无变更则不动。 diff --git a/deploy/certbot/cloudflare.ini.tmpl b/deploy/certbot/cloudflare.ini.tmpl new file mode 100644 index 0000000..e362ed5 --- /dev/null +++ b/deploy/certbot/cloudflare.ini.tmpl @@ -0,0 +1,3 @@ +# Cloudflare DNS-01 凭据(渲染为 deploy/secrets/cloudflare.ini,权限 600,gitignore)。 +# Token 需具备目标 zone 的 Zone:DNS:Edit 权限。 +dns_cloudflare_api_token = __CF_API_TOKEN__ diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 5f15aaa..1308ffb 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,28 +1,58 @@ -# Pangolin Phase-0 数据面:Xray(VLESS+REALITY) + sing-box(Hysteria2) -# 独立 project 名 pangolin,与现网 marzban/blog/jiu 完全隔离。 -# 镜像固定版本,避免 latest 漂移(EC2 实测:Xray 26.3.27 / sing-box 1.13.12)。 -name: pangolin +# pangolin 数据/分发面(方案 C)。 +# 与现网 marzban/jiu/billing/blog 并存:edge 反代到它们,其余容器不动。 +# 镜像固定版本(EC2 实测:nginx 1.27 / sing-box 1.13.12 / xray 26.3.27)。 +# project 名由命令行 `-p pangolin` 指定(兼容宿主 docker-compose 版本)。 services: + # 分发层:接管 80/443,移植现网全部路由(host 网络才能绑端口并回连 127.0.0.1 后端) + edge: + image: nginx:1.27-alpine + container_name: pangolin-edge + restart: unless-stopped + network_mode: host + volumes: + - ./edge/nginx.conf:/etc/nginx/nginx.conf:ro + - ./edge/stream.conf:/etc/nginx/stream.conf:ro + - ./edge/conf.d:/etc/nginx/conf.d:ro + - /etc/letsencrypt:/etc/letsencrypt:ro # 证书(只读) + - /opt/jiu:/opt/jiu:ro # jiu 静态(images/web/marketing) + - /var/www/pay:/var/www/pay:ro # 收款页静态 + healthcheck: + test: ["CMD", "nginx", "-t"] + interval: 1m + timeout: 10s + retries: 3 + + # Hysteria2 加速线(UDP 443) + singbox: + image: ghcr.io/sagernet/sing-box:v1.13.12 + container_name: pangolin-singbox + restart: unless-stopped + network_mode: host + volumes: + - ./singbox/config.json:/etc/sing-box/config.json:ro + - ./secrets/hy2:/etc/sing-box/cert:ro + command: ["run", "-c", "/etc/sing-box/config.json"] + + # 证书续期(DNS-01 / Cloudflare)—— 按需运行:`docker compose run --rm certbot renew` + # 由宿主 cron 每日调用,renew 后宿主再 `docker exec pangolin-edge nginx -s reload`。 + certbot: + image: certbot/dns-cloudflare:latest + container_name: pangolin-certbot + profiles: ["certbot"] + volumes: + - /etc/letsencrypt:/etc/letsencrypt + - ./secrets/cloudflare.ini:/etc/cloudflare.ini:ro + entrypoint: ["certbot"] + + # 新节点用的 REALITY —— 本台不启用(REALITY 仍由 Marzban 提供) xray: image: ghcr.io/xtls/xray-core:26.3.27 container_name: pangolin-xray + profiles: ["newnode"] restart: unless-stopped - # 仅监听本地 11443,由宿主 nginx 的 ssl_preread 按 SNI(www.apple.com)转入 ports: - "127.0.0.1:11443:11443" volumes: - ./xray/config.json:/etc/xray/config.json:ro command: ["run", "-c", "/etc/xray/config.json"] - - singbox: - image: ghcr.io/sagernet/sing-box:v1.13.12 - container_name: pangolin-singbox - restart: unless-stopped - # Hysteria2 直接对外暴露 UDP 443(与 TCP 443 不同协议栈,互不冲突) - ports: - - "443:443/udp" - volumes: - - ./singbox/config.json:/etc/sing-box/config.json:ro - - ./secrets/hy2:/etc/sing-box/cert:ro - command: ["run", "-c", "/etc/sing-box/config.json"] diff --git a/deploy/edge/conf.d/blog.conf b/deploy/edge/conf.d/blog.conf new file mode 100644 index 0000000..d930fd3 --- /dev/null +++ b/deploy/edge/conf.d/blog.conf @@ -0,0 +1,37 @@ +server { + server_name blog.51yanmei.com; + + # Blog (Next.js + Payload CMS) + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $http_cf_connecting_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_cache_bypass $http_upgrade; + } + + # Umami stats — proxied under /stats/ + location /stats/ { + proxy_pass http://127.0.0.1:3001/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $http_cf_connecting_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + + listen 127.0.0.1:8444 ssl; + ssl_certificate /etc/letsencrypt/live/blog.51yanmei.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/blog.51yanmei.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; +} + +server { + listen 80; + server_name blog.51yanmei.com; + return 301 https://$host$request_uri; +} diff --git a/deploy/edge/conf.d/jiu.conf b/deploy/edge/conf.d/jiu.conf new file mode 100644 index 0000000..07105c3 --- /dev/null +++ b/deploy/edge/conf.d/jiu.conf @@ -0,0 +1,73 @@ +server { + listen 127.0.0.1:8445 ssl; + server_name jiu.51yanmei.com; + + ssl_certificate /etc/letsencrypt/live/jiu.51yanmei.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/jiu.51yanmei.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + client_max_body_size 20m; + + # 商品图片静态文件 + location ^~ /images/ { + alias /opt/jiu/images/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # 文件导入接口(超时更长) + location ~ ^/api/v1/import/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 300s; + } + + # API 反向代理 + location ~ ^/(api|health|version) { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 30s; + } + + # Flutter 管理端(仅 /app/ 前缀,不污染根路径) + location /app/ { + alias /opt/jiu/web/; + index index.html; + try_files $uri $uri/ /app/index.html; + } + + # index.html 不缓存,确保每次发版后浏览器加载最新版 + location = /app/index.html { + alias /opt/jiu/web/index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理 + location ~ ^/product/ { + root /opt/jiu/web; + try_files $uri /app/index.html; + } + + # 扫码页(/scan/ → scan.html,JS 从 URL 读取 id) + location /scan/ { + root /opt/jiu/marketing; + try_files /scan.html =404; + } + + # 营销站点兜底(根路径及所有其他路径) + location / { + root /opt/jiu/marketing; + try_files $uri $uri.html $uri/index.html /index.html; + } +} + +server { + listen 80; + server_name jiu.51yanmei.com; + return 301 https://$host$request_uri; +} diff --git a/deploy/edge/conf.d/marzban.conf b/deploy/edge/conf.d/marzban.conf new file mode 100644 index 0000000..3c76de1 --- /dev/null +++ b/deploy/edge/conf.d/marzban.conf @@ -0,0 +1,26 @@ +server { + server_name vpn.51yanmei.com; + + location / { + proxy_pass http://127.0.0.1:8899; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $http_cf_connecting_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + + listen 127.0.0.1:8443 ssl; + ssl_certificate /etc/letsencrypt/live/vpn.51yanmei.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/vpn.51yanmei.com/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} +server { + if ($host = vpn.51yanmei.com) { + return 301 https://$host$request_uri; + } + + listen 80; + server_name vpn.51yanmei.com; + return 404; +} diff --git a/deploy/edge/conf.d/pangolin.conf b/deploy/edge/conf.d/pangolin.conf new file mode 100644 index 0000000..e320991 --- /dev/null +++ b/deploy/edge/conf.d/pangolin.conf @@ -0,0 +1,4 @@ +# pangolin 自有路由占位。 +# 本台(EC2)REALITY 仍由 Marzban 提供(stream.conf 里 default/cloudflare → 10443), +# 故此处暂空。将来退役 Marzban / 接入 pangolin-xray 时,在 stream.conf 增加 +# www.apple.com 127.0.0.1:11443; 并启用 pangolin-xray 容器即可。 diff --git a/deploy/edge/conf.d/pay.conf b/deploy/edge/conf.d/pay.conf new file mode 100644 index 0000000..f099406 --- /dev/null +++ b/deploy/edge/conf.d/pay.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name pay.51yanmei.com; + + root /var/www/pay; + index index.html; + + location = /health { + default_type application/json; + return 200 "{\"ok\":true}"; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/edge/nginx.conf b/deploy/edge/nginx.conf new file mode 100644 index 0000000..6aa1ccd --- /dev/null +++ b/deploy/edge/nginx.conf @@ -0,0 +1,41 @@ +# pangolin-edge 主配置(容器内 /etc/nginx/nginx.conf)。 +# 官方 nginx 镜像已内置 stream 模块,无需 load_module。 +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; + + 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; + types_hash_max_size 4096; + + # 默认站:未匹配域名的 80 兜底 + server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + root /usr/share/nginx/html; + error_page 404 /404.html; + error_page 500 502 503 504 /50x.html; + } + + # 业务 vhost(blog / jiu / marzban面板 / pay)—— 逐字移植现网 + include /etc/nginx/conf.d/*.conf; +} + +# 443/TCP SNI 分流 +include /etc/nginx/stream.conf; diff --git a/deploy/edge/stream.conf b/deploy/edge/stream.conf new file mode 100644 index 0000000..501ebc8 --- /dev/null +++ b/deploy/edge/stream.conf @@ -0,0 +1,20 @@ +# 443/TCP SNI 分流(ssl_preread)—— 逐字沿用现网,后端地址不变。 +# host 网络下容器内的 127.0.0.1 即宿主 loopback,可直达各后端。 +stream { + map $ssl_preread_server_name $backend { + www.cloudflare.com 127.0.0.1:10443; # Marzban REALITY(线上代理) + vpn.51yanmei.com 127.0.0.1:8443; # Marzban 面板(本 nginx http) + blog.51yanmei.com 127.0.0.1:8444; # 博客(本 nginx http) + jiu.51yanmei.com 127.0.0.1:8445; # jiu(本 nginx http) + default 127.0.0.1:10443; # 兜底 → Marzban REALITY + } + + server { + listen 443; + listen [::]:443; + proxy_pass $backend; + ssl_preread on; + proxy_connect_timeout 10s; + proxy_timeout 3600s; + } +} diff --git a/deploy/nginx/pangolin-nginx-apply.sh b/deploy/nginx/pangolin-nginx-apply.sh deleted file mode 100755 index d1153bf..0000000 --- a/deploy/nginx/pangolin-nginx-apply.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# pangolin-nginx-apply.sh —— 受限特权脚本(以 root 安装到 /usr/local/sbin/) -# -# 作用:把 pangolin REALITY 的 SNI 路由 (www.apple.com -> 127.0.0.1:11443) -# 幂等地插入宿主机 nginx 的 stream map,并热重载。 -# -# 安全设计: -# - 唯一被 sudoers 授权 NOPASSWD 的命令,作用域锁死本脚本; -# - 幂等:已存在则直接退出; -# - 改前备份 + nginx -t 校验 + 失败自动回滚,不影响其他服务(热 reload 不断连)。 -set -euo pipefail - -STREAM_CONF="/etc/nginx/stream.conf" -BACKEND="127.0.0.1:11443" -SNI="www.apple.com" -ROUTE_LINE=" ${SNI} ${BACKEND};" - -if [[ ! -f "$STREAM_CONF" ]]; then - echo "[nginx-apply] $STREAM_CONF 不存在,放弃(本机预期已有 nginx)" >&2 - exit 1 -fi - -# 幂等:路由已存在则跳过 -if grep -qF "$BACKEND" "$STREAM_CONF"; then - echo "[nginx-apply] 路由已存在,跳过" - exit 0 -fi - -BACKUP="${STREAM_CONF}.pangolin.bak" -cp -a "$STREAM_CONF" "$BACKUP" - -TMP="$(mktemp)" -# 在 map 的 default 行之前插入我们的 SNI 路由 -awk -v ins="$ROUTE_LINE" ' - $0 ~ /default[[:space:]]+127\.0\.0\.1:10443;/ && !done { print ins; done=1 } - { print } -' "$STREAM_CONF" > "$TMP" - -# 校验:确实插入了我们的后端,且没有破坏文件 -if ! grep -qF "$BACKEND" "$TMP"; then - echo "[nginx-apply] 插入失败(未找到 default 锚点?),不改动" >&2 - rm -f "$TMP" - exit 1 -fi - -cat "$TMP" > "$STREAM_CONF" -rm -f "$TMP" - -if nginx -t 2>/dev/null; then - nginx -s reload - echo "[nginx-apply] 已插入路由并热重载成功" -else - echo "[nginx-apply] nginx -t 失败,回滚" >&2 - cp -a "$BACKUP" "$STREAM_CONF" - exit 1 -fi diff --git a/deploy/nginx/pangolin.stream-line b/deploy/nginx/pangolin.stream-line deleted file mode 100644 index 4108eef..0000000 --- a/deploy/nginx/pangolin.stream-line +++ /dev/null @@ -1 +0,0 @@ - www.apple.com 127.0.0.1:11443; diff --git a/deploy/nginx/stream.full.tmpl b/deploy/nginx/stream.full.tmpl deleted file mode 100644 index 4ef9ce6..0000000 --- a/deploy/nginx/stream.full.tmpl +++ /dev/null @@ -1,23 +0,0 @@ -# 仅当宿主机【没有】nginx 时使用的完整 stream 配置(deploy.sh 自动判断)。 -# 本机已有宿主 nginx,故此文件不会被使用 —— 留作「无 nginx 则先部署」的兜底。 -# -# 用法(deploy.sh 在无 nginx 分支里): -# 1. 安装 nginx + stream 模块 -# 2. 将本文件渲染到 /etc/nginx/stream.conf 并 include -# 3. nginx -t && systemctl enable --now nginx - -stream { - map $ssl_preread_server_name $pangolin_backend { - www.apple.com 127.0.0.1:11443; # pangolin REALITY - default 127.0.0.1:11443; - } - - server { - listen 443; - listen [::]:443; - proxy_pass $pangolin_backend; - ssl_preread on; - proxy_connect_timeout 10s; - proxy_timeout 3600s; - } -} diff --git a/deploy/scripts/cutover.sh b/deploy/scripts/cutover.sh new file mode 100755 index 0000000..00dbc71 --- /dev/null +++ b/deploy/scripts/cutover.sh @@ -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 diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index 6d0f85c..f94fddf 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -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 - 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 "==> 完成 ✅" diff --git a/deploy/scripts/gen-secrets.sh b/deploy/scripts/gen-secrets.sh index 88a59e6..eb1b7f8 100755 --- a/deploy/scripts/gen-secrets.sh +++ b/deploy/scripts/gen-secrets.sh @@ -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" < "$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 diff --git a/deploy/scripts/print-clients.sh b/deploy/scripts/print-clients.sh index efa01c1..afed91a 100755 --- a/deploy/scripts/print-clients.sh +++ b/deploy/scripts/print-clients.sh @@ -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" <&2 - exit 1 -fi - -SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)" # -> 本脚本所在的 deploy/(可在 /tmp 暂存或 /opt/pangolin) -APPLY_SRC="${SRC_DIR}/nginx/pangolin-nginx-apply.sh" -APPLY_DST="/usr/local/sbin/pangolin-nginx-apply.sh" -SUDOERS="/etc/sudoers.d/pangolin" -DEPLOY_USER="${PANGOLIN_DEPLOY_USER:-ec2-user}" -DEPLOY_DIR="${PANGOLIN_DEPLOY_DIR:-/opt/pangolin}" - -[[ -f "$APPLY_SRC" ]] || { echo "找不到 $APPLY_SRC" >&2; exit 1; } - -# 1) 部署目录 -mkdir -p "$DEPLOY_DIR" -chown "${DEPLOY_USER}:${DEPLOY_USER}" "$DEPLOY_DIR" -echo "[root-setup] 已准备部署目录 $DEPLOY_DIR(归属 ${DEPLOY_USER})" - -install -o root -g root -m 0755 "$APPLY_SRC" "$APPLY_DST" -echo "[root-setup] 已安装 $APPLY_DST" - -cat > "$SUDOERS" </dev/null && echo "[root-setup] 已安装并校验 $SUDOERS" - -echo "[root-setup] 完成。现在 ${DEPLOY_USER} 可执行: sudo ${APPLY_DST}"