方案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
+8 -11
View File
@@ -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"
+5 -5
View File
@@ -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
+43 -34
View File
@@ -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 无变更则不动。
+3
View File
@@ -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__
+47 -17
View File
@@ -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"]
+37
View File
@@ -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;
}
+73
View File
@@ -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/<id> → scan.htmlJS 从 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;
}
+26
View File
@@ -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;
}
+4
View File
@@ -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 容器即可。
+16
View File
@@ -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;
}
}
+41
View File
@@ -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;
+20
View File
@@ -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;
}
}
-56
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
www.apple.com 127.0.0.1:11443;
-23
View File
@@ -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;
}
}
+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 取用)"
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
# root-setup.sh —— 一次性 root 前置(在 EC2 上以 root 执行一次)。
# sudo bash /opt/pangolin/setup/root-setup.sh
#
# 作用:
# 1. 创建部署目录 /opt/pangolin(归属部署用户,后续 CI/手动 rsync 写入)
# 2. 安装受限特权脚本到 /usr/local/sbin/pangolin-nginx-apply.sh(root:root 0755)
# 3. 安装 sudoers 片段,授权 ec2-user 仅 NOPASSWD 调用上述脚本
# 之后 CI(ec2-user)即可非交互地增量更新 nginx 路由,且无法获得通用 root。
set -euo pipefail
if [[ "$(id -u)" -ne 0 ]]; then
echo "请以 root 运行:sudo bash $0" >&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" <<EOF
${DEPLOY_USER} ALL=(root) NOPASSWD: ${APPLY_DST}
EOF
chmod 0440 "$SUDOERS"
visudo -cf "$SUDOERS" >/dev/null && echo "[root-setup] 已安装并校验 $SUDOERS"
echo "[root-setup] 完成。现在 ${DEPLOY_USER} 可执行: sudo ${APPLY_DST}"