chore(deploy): 删除 EC2 部署栈,转向 RackNerd 自建节点栈

EC2(marzban 共存)分发面不再由本仓库管理,全面转向新 VPS 自建。删除:
- deploy/{edge,singbox,xray,certbot,backup,scripts,docker-compose.yml,.env.example,README.md}
- .gitea/workflows/deploy.yml(EC2 自动部署 CD)
- ci/nginx-test.sh(edge nginx 校验)
保留 deploy/{bootstrap,single-node}(新节点初始化 + 全套自建)。

ci.yml 去掉 EC2 相关 job(compose/nginx 校验、edge 镜像构建),
shellcheck 改为校验 bootstrap/single-node 脚本;openapi/脱敏/flutter 不变。
deadman-watch 的"EC2"特指改为"任意常在线主机"。

注:EC2 上正在跑的服务不受影响(仅本仓库不再管理它)。文档(CLAUDE.md、
docs/回滚手册.md、app/kernel/poc/README.md)仍引用旧栈,待单独更新。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-18 06:55:25 +08:00
parent ff9a40277d
commit bf81c786b2
35 changed files with 16 additions and 2280 deletions
+9 -44
View File
@@ -1,6 +1,6 @@
name: ci-pangolin
# 触发条件:deploy/ 或 design/ 或 ci/ 变更时,以及所有向 main 的 PR
# 触发条件:deploy/ 或 design/ 或 client/ 或 ci/ 变更时,以及所有向 main 的 PR
on:
push:
branches: [main]
@@ -24,40 +24,18 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: shellcheck deploy/scripts/*.sh
- name: shellcheck 节点脚本(bootstrap + single-node)
run: |
docker run --rm \
-v "$PWD/deploy:/mnt/deploy:ro" \
koalaman/shellcheck:stable \
shellcheck -S warning \
/mnt/deploy/scripts/gen-secrets.sh \
/mnt/deploy/scripts/print-clients.sh \
/mnt/deploy/scripts/deploy.sh \
/mnt/deploy/scripts/cutover.sh \
/mnt/deploy/scripts/rollback.sh
/mnt/deploy/bootstrap/init.sh \
/mnt/deploy/bootstrap/monitor/pangolin-monitor.sh \
/mnt/deploy/bootstrap/monitor/deadman-watch.sh \
/mnt/deploy/single-node/deploy.sh
- name: shellcheck deploy/edge/pangolin-entrypoint.sh
run: |
docker run --rm \
-v "$PWD/deploy/edge:/mnt/edge:ro" \
koalaman/shellcheck:stable \
shellcheck -S warning /mnt/edge/pangolin-entrypoint.sh
# ── Job 2: Unit Tests ────────────────────────────────────────────────────
unit-test:
name: Unit Tests — nginx cfg + compose
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
- name: validate docker-compose.yml (syntax)
run: docker-compose -f deploy/docker-compose.yml config -q
- name: nginx config lint with stub certs
run: bash ci/nginx-test.sh
# ── Job 3: OpenAPI Sync Check ────────────────────────────────────────────
# ── Job 2: OpenAPI Sync Check ────────────────────────────────────────────
openapi-check:
name: OpenAPI Sync Check
runs-on: nas
@@ -74,7 +52,7 @@ jobs:
sh -c "pip install openapi-spec-validator --quiet && \
python -m openapi_spec_validator /spec/openapi.yaml"
# ── Job 4: Redline Word Scan (脱敏) ──────────────────────────────────────
# ── Job 3: Redline Word Scan (脱敏) ──────────────────────────────────────
redline-scan:
name: Redline Scan — 脱敏 (UI 文案)
runs-on: nas
@@ -85,9 +63,7 @@ jobs:
- name: scan UI text resources for prohibited words
run: bash ci/scan-redline.sh
# ── Job 5: Flutter 客户端(分析 + 单测/组件测试)────────────────────────
# golden 基准图需本地 `flutter test --update-goldens` 生成并提交后,
# 再把 test/golden 纳入下方测试范围;此处先跑 unit + widget,保证 CI 稳定。
# ── Job 4: Flutter 客户端(分析 + 单测/组件测试)────────────────────────
flutter-client:
name: Flutter — analyze + test
runs-on: nas
@@ -101,14 +77,3 @@ jobs:
-v "$PWD/client:/app" -w /app \
ghcr.io/cirruslabs/flutter:stable \
bash -c "flutter pub get && flutter analyze && flutter test test/unit test/widget"
# ── Job 6: Container Image Build ────────────────────────────────────────
image-build:
name: Image Build — pangolin-edge
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
- name: build pangolin-edge:ci
run: docker build -t pangolin-edge:ci ./deploy/edge
-46
View File
@@ -1,46 +0,0 @@
name: deploy-pangolin
on:
push:
branches: [main]
paths:
- 'deploy/**'
- '.gitea/workflows/deploy.yml'
workflow_dispatch:
jobs:
deploy:
# ⚠️ 改成你 NAS 上 act_runner 的实际 label(Gitea→Settings→Actions→Runners)
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
- 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)
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)
run: |
rsync -az \
--exclude 'secrets' \
--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 }}:pangolin/"
- name: 远程部署(免 root,幂等)
run: |
ssh -i ~/.ssh/ec2_key -o StrictHostKeyChecking=accept-new \
"${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}" \
"export CF_API_TOKEN='${{ secrets.CF_API_TOKEN }}'; cd ~/pangolin && chmod +x scripts/*.sh && ./scripts/deploy.sh"
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# nginx-test.sh — CI 环境下校验 nginx 配置,用自签桩证书代替真实 Let's Encrypt 证书。
# 依赖: docker、openssl
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CERT_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$CERT_DIR"
}
trap cleanup EXIT INT TERM
echo "→ 生成自签桩证书 ..."
# 三个需要证书的域名
for domain in vpn.51yanmei.com blog.51yanmei.com jiu.51yanmei.com; do
mkdir -p "${CERT_DIR}/live/${domain}"
openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:P-256 -days 1 \
-keyout "${CERT_DIR}/live/${domain}/privkey.pem" \
-out "${CERT_DIR}/live/${domain}/fullchain.pem" \
-subj "/CN=${domain}" 2>/dev/null
done
# options-ssl-nginx.conf 桩(nginx -t 仅检查文件可读,不校验内容)
printf '# stub\n' > "${CERT_DIR}/options-ssl-nginx.conf"
# ssl-dhparams.pem 桩(生成 512-bit DH,足够 nginx 解析,CI 不要求安全强度)
openssl dhparam -out "${CERT_DIR}/ssl-dhparams.pem" 512 2>/dev/null
# 桩目录(nginx alias/root 在 -t 时不校验是否存在,但 volume 挂载需目录存在)
mkdir -p "${CERT_DIR}/jiu/images" "${CERT_DIR}/jiu/web" "${CERT_DIR}/jiu/marketing"
mkdir -p "${CERT_DIR}/www-pay"
echo "→ 运行 nginx -t ..."
docker run --rm \
-v "${REPO_ROOT}/deploy/edge/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "${REPO_ROOT}/deploy/edge/stream.conf:/etc/nginx/stream.conf:ro" \
-v "${REPO_ROOT}/deploy/edge/conf.d:/etc/nginx/conf.d:ro" \
-v "${CERT_DIR}:/etc/letsencrypt:ro" \
-v "${CERT_DIR}/jiu:/opt/jiu:ro" \
-v "${CERT_DIR}/www-pay:/var/www/pay:ro" \
nginx:1.27-alpine nginx -t
echo "✅ nginx 配置校验通过。"
-8
View File
@@ -1,8 +0,0 @@
# 可选环境变量。真正的密钥由 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
-81
View File
@@ -1,81 +0,0 @@
# Pangolin 部署(方案 C:分发层容器化)
把流量分发层(nginx)迁入 Docker,新增 Hysteria2,经 Gitea Actions 自动部署。
与现网 marzban/jiu/billing/blog 并存。设计见 [`../docs/技术方案.md`](../docs/技术方案.md) 与
计划 `~/.claude/plans/ci-cd-1-shimmering-panda.md`
## 容器一览(部署后)
| 容器 | 用途 | 端口/网络 |
|------|------|-----------|
| `pangolin-edge`(nginx) | 分发层,接管 80/443,移植现网全部路由 | host 网络 |
| `pangolin-singbox` | Hysteria2 加速线 | host 网络,443/udp |
| `pangolin-backup` | 数据库备份:xtrabackup+pg_dumpage 加密,S3 异地 | host 网络 |
| `pangolin-certbot` | DNS-01(Cloudflare)证书续期,按需运行 | 无端口 |
| `pangolin-xray`(本台不启用) | REALITY,留给新节点 | profile `newnode` |
现网保留不动:`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
```
失败秒级回滚:
```bash
docker-compose -p pangolin stop edge && sudo systemctl start nginx
```
## 证书续期(自动,无需宿主 cron)
`pangolin-edge` 是自定义镜像(官方 nginx + certbot),**容器内每 12h 自动 `certbot renew`(DNS-01/Cloudflare),续期后在本容器内 `nginx -s reload`**。见 `edge/pangolin-entrypoint.sh`。无需宿主 cron、无需 docker socket。
```bash
# 手动验证续期可用(staging,不动正式证书)
docker exec pangolin-edge certbot renew --dry-run
# 首次签发/重配(仅手动,用独立 certbot 服务)
docker-compose -p pangolin run --rm certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/cloudflare.ini -d <域名> --cert-name <域名>
```
> 证书文件在 `/etc/letsencrypt`(rw 挂载进 edge);CF 凭据 `secrets/cloudflare.ini` 挂到 `/etc/cloudflare.ini`。
## 取客户端导入串(Hy2,含口令)
```bash
ssh ec2 'cat ~/pangolin/secrets/clients.txt'
```
## 备份与灾备(RPO ≤ 15min / RTO ≤ 4h)
见 [`docs/备份与灾备.md`](../docs/备份与灾备.md) 完整说明。快速命令:
```bash
# 初始化备份配置(首次)
cp backup/backup.env.example secrets/backup.env && vim secrets/backup.env
docker build -t pangolin-backup:local ./backup
docker-compose -p pangolin up -d backup
# 查看实时 RPO/RTO 状态
docker exec pangolin-backup /scripts/verify-rpo-rto.sh
# 手动触发全量备份
docker exec pangolin-backup bash -c '. /etc/backup-env.sh && /scripts/backup-full.sh'
# 月度 DR 演练(含私钥时解密验证)
docker exec pangolin-backup bash -c '. /etc/backup-env.sh && /scripts/dr-drill.sh'
```
-54
View File
@@ -1,54 +0,0 @@
FROM ubuntu:22.04
ARG DEBIAN_FRONTEND=noninteractive
# ---------- 基础工具 ----------
RUN apt-get update && apt-get install -y --no-install-recommends \
curl wget ca-certificates gnupg2 lsb-release \
postgresql-client \
sqlite3 \
pigz pv \
unzip \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
# ---------- AWS CLI v2 ----------
RUN set -e \
&& curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
&& unzip -q /tmp/awscliv2.zip -d /tmp/ \
&& /tmp/aws/install \
&& rm -rf /tmp/awscliv2.zip /tmp/aws
# ---------- age 加密工具 v1.2.0 ----------
RUN set -e \
&& AGE_VERSION=v1.2.0 \
&& wget -q -O /tmp/age.tar.gz \
"https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-amd64.tar.gz" \
&& tar -xf /tmp/age.tar.gz -C /tmp/ \
&& install -m 755 /tmp/age/age /usr/local/bin/age \
&& install -m 755 /tmp/age/age-keygen /usr/local/bin/age-keygen \
&& rm -rf /tmp/age*
# ---------- Percona XtraBackup 8.0 ----------
RUN set -e \
&& DISTRO=$(lsb_release -cs) \
&& wget -q -O /tmp/percona-release.deb \
"https://repo.percona.com/apt/percona-release_latest.${DISTRO}_all.deb" \
&& dpkg -i /tmp/percona-release.deb \
&& percona-release setup pxb80 \
&& apt-get update && apt-get install -y --no-install-recommends percona-xtrabackup-80 \
&& rm /tmp/percona-release.deb \
&& rm -rf /var/lib/apt/lists/*
# ---------- 备份脚本 ----------
COPY scripts/ /scripts/
COPY entrypoint.sh /entrypoint.sh
COPY crontab.txt /etc/cron.d/pangolin-backup
RUN chmod +x /scripts/*.sh /entrypoint.sh \
&& chmod 0644 /etc/cron.d/pangolin-backup \
&& mkdir -p /backup /var/log/backup
# cron 日志直接输出到 stdout/stderr
RUN touch /var/log/backup/cron.log
ENTRYPOINT ["/entrypoint.sh"]
-75
View File
@@ -1,75 +0,0 @@
# ============================================================
# Pangolin 备份系统环境变量
# 复制此文件为 deploy/secrets/backup.env 并填写真实值
# deploy/secrets/ 已在 .gitignore,不会提交到版本库
# ============================================================
# ──────────────────────────────────────────────────────────
# S3 备份目标(独立身份账号,与生产 AWS 账号隔离)
# 创建步骤见 docs/备份与灾备.md § 初始化
# ──────────────────────────────────────────────────────────
BACKUP_S3_BUCKET=pangolin-backup-XXXXXX # 替换为实际 bucket 名
BACKUP_S3_PREFIX=ec2-ap-southeast-1 # 按服务器区分前缀
BACKUP_AWS_REGION=ap-southeast-1
# 备份专用 IAM 凭据(仅有 s3:PutObject 权限)
BACKUP_AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
BACKUP_AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
# ──────────────────────────────────────────────────────────
# age 加密密钥
# 生成方式: age-keygen 2>&1 | tee age-keypair.txt
# 公钥放此处(明文 OK);私钥存离线,灾备演练时临时注入
# ──────────────────────────────────────────────────────────
AGE_PUBLIC_KEY=age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# age 私钥(可选,DR 演练容器需要;不设则演练只做结构验证,不做解密恢复)
# AGE_PRIVATE_KEY=AGE-SECRET-KEY-1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# ──────────────────────────────────────────────────────────
# MySQL (jiu_mysql, marzban)
# ──────────────────────────────────────────────────────────
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=backup_user # 专用备份账号,权限见下方
MYSQL_PASSWORD=CHANGE_ME
# 如果 MySQL 数据目录可以挂载到容器(xtrabackup 需要),设置此项
# Docker 内 MySQL: 通常 /var/lib/docker/volumes/<vol>/_data
# 宿主原生 MySQL: /var/lib/mysql
# 留空则 xtrabackup 尝试从 MySQL 连接获取(需要 BACKUP_ADMIN 权限)
MYSQL_DATADIR=
# ──────────────────────────────────────────────────────────
# PostgreSQL (blog, umami)
# ──────────────────────────────────────────────────────────
PG_HOST=127.0.0.1
PG_PORT=5432
PG_USER=postgres
PG_PASSWORD=CHANGE_ME
PG_DATABASES=blog,umami # 逗号分隔,按需填写
# ──────────────────────────────────────────────────────────
# Marzban SQLite
# ──────────────────────────────────────────────────────────
MARZBAN_DB_PATH=/var/lib/marzban/db.sqlite3
# ──────────────────────────────────────────────────────────
# 本地临时备份目录(容器内路径)
# ──────────────────────────────────────────────────────────
BACKUP_DIR=/backup
# ──────────────────────────────────────────────────────────
# 通知 Webhook(可选)
# 支持格式:POST JSON {"level":"INFO|ERROR","msg":"..."}
# 可对接 PushDeer / 企业微信 / Slack 等
# ──────────────────────────────────────────────────────────
# NOTIFY_WEBHOOK_URL=https://api.pushdeer.com/message/push?pushkey=XXXXX
# ──────────────────────────────────────────────────────────
# MySQL 备份账号权限(在 MySQL 中执行)
# CREATE USER 'backup_user'@'%' IDENTIFIED BY 'CHANGE_ME';
# GRANT RELOAD, LOCK TABLES, REPLICATION CLIENT, CREATE TABLESPACE,
# PROCESS, SUPER, CREATE, INSERT, SELECT, BACKUP_ADMIN ON *.* TO 'backup_user'@'%';
# FLUSH PRIVILEGES;
# ──────────────────────────────────────────────────────────
-20
View File
@@ -1,20 +0,0 @@
# Pangolin 备份计划任务
# 此文件由 Dockerfile 复制到 /etc/cron.d/pangolin-backup
# RPO ≤ 15min:全量每日 02:00 + 增量每 15min
# 月度 DR 演练:每月 1 日 03:00
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=""
# --- 每日全量备份(MySQL xtrabackup + pg_dump + SQLite ---
0 2 * * * root . /etc/backup-env.sh && /scripts/backup-full.sh >> /var/log/backup/cron.log 2>&1
# --- 每 15 分钟增量备份(MySQL binlog + pg_dump ---
*/15 * * * * root . /etc/backup-env.sh && /scripts/backup-inc.sh >> /var/log/backup/cron.log 2>&1
# --- 每月 1 日 03:00 灾备恢复演练 ---
0 3 1 * * root . /etc/backup-env.sh && /scripts/dr-drill.sh >> /var/log/backup/dr-drill.log 2>&1
# --- 每天 06:00 清理 7 天前的本地临时文件 ---
0 6 * * * root find /backup -name "*.tmp" -mtime +1 -delete; find /backup/encrypt -mtime +2 -delete >> /var/log/backup/cron.log 2>&1
-88
View File
@@ -1,88 +0,0 @@
#!/usr/bin/env bash
# entrypoint.sh —— 启动备份容器,初始化环境,拉起 crond
set -euo pipefail
LOG_FILE=/var/log/backup/cron.log
# ---------- 检查必要变量 ----------
required_vars="BACKUP_S3_BUCKET BACKUP_S3_PREFIX AGE_PUBLIC_KEY
BACKUP_AWS_ACCESS_KEY_ID BACKUP_AWS_SECRET_ACCESS_KEY"
for v in $required_vars; do
if [ -z "${!v:-}" ]; then
echo "[ERROR] 缺少必要环境变量: ${v}" >&2
exit 1
fi
done
# ---------- 配置 AWS 凭据(备份专用 IAM 账号) ----------
mkdir -p /root/.aws
cat > /root/.aws/credentials << AWSEOF
[backup]
aws_access_key_id = ${BACKUP_AWS_ACCESS_KEY_ID}
aws_secret_access_key = ${BACKUP_AWS_SECRET_ACCESS_KEY}
AWSEOF
cat > /root/.aws/config << AWSEOF
[profile backup]
region = ${BACKUP_AWS_REGION:-ap-southeast-1}
output = json
AWSEOF
chmod 600 /root/.aws/credentials /root/.aws/config
# ---------- 写入 age 公钥 ----------
echo "${AGE_PUBLIC_KEY}" > /etc/age-public.txt
chmod 644 /etc/age-public.txt
# ---------- 写入 age 私钥(仅 DR 演练时需要) ----------
if [ -n "${AGE_PRIVATE_KEY:-}" ]; then
echo "${AGE_PRIVATE_KEY}" > /etc/age-private.txt
chmod 600 /etc/age-private.txt
fi
# ---------- 把所有环境变量写入 /etc/backup-env.sh,供 cron job 使用 ----------
# cron 不继承环境变量)
{
echo "# 自动生成,cron job 使用"
for v in BACKUP_S3_BUCKET BACKUP_S3_PREFIX BACKUP_AWS_REGION \
BACKUP_AWS_ACCESS_KEY_ID BACKUP_AWS_SECRET_ACCESS_KEY \
MYSQL_HOST MYSQL_PORT MYSQL_USER MYSQL_PASSWORD MYSQL_DATABASES \
MYSQL_DATADIR \
PG_HOST PG_PORT PG_USER PG_PASSWORD PG_DATABASES \
MARZBAN_DB_PATH \
BACKUP_DIR \
NOTIFY_WEBHOOK_URL; do
val="${!v:-}"
if [ -n "${val}" ]; then
printf 'export %s=%q\n' "${v}" "${val}"
fi
done
echo 'export AWS_PROFILE=backup'
echo 'export AWS_CONFIG_FILE=/root/.aws/config'
echo 'export AWS_SHARED_CREDENTIALS_FILE=/root/.aws/credentials'
} > /etc/backup-env.sh
chmod 600 /etc/backup-env.sh
# ---------- 设置本地备份目录 ----------
BACKUP_DIR="${BACKUP_DIR:-/backup}"
mkdir -p \
"${BACKUP_DIR}/mysql/full" \
"${BACKUP_DIR}/mysql/inc" \
"${BACKUP_DIR}/mysql/binlog" \
"${BACKUP_DIR}/postgres" \
"${BACKUP_DIR}/marzban" \
"${BACKUP_DIR}/encrypt"
echo "[INFO] 备份容器启动成功,等待 cron 任务..."
echo "[INFO] S3 目标: s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}"
echo "[INFO] 备份目录: ${BACKUP_DIR}"
# ---------- 启动 cron 并保持前台 ----------
# 执行一次启动验证
/scripts/verify-rpo-rto.sh --status-only 2>&1 | head -5 || true
# 启动 cron daemon(后台,Ubuntu 的 cron 默认 daemon 模式)
cron
# exec tail 成为主进程,接收 Docker stop 信号,并实时输出日志到 stdout
exec tail -F "${LOG_FILE}"
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env bash
# backup-full.sh —— 每日全量备份编排脚本(02:00 运行)
# 内容:MySQL xtrabackup 全量 + PostgreSQL pg_dump + Marzban SQLite
# 备份 → age 加密 → 上传 S3
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
START="$(timer_start)"
log_info "========== 全量备份开始 ${TIMESTAMP} =========="
ERRORS=0
# ──────────────────────────────────────────────────────
# 1. MySQL 全量备份(xtrabackup
# ──────────────────────────────────────────────────────
mysql_full_backup() {
local target="${BACKUP_DIR}/mysql/full/${TIMESTAMP}"
local archive="${BACKUP_DIR}/mysql/full/${TIMESTAMP}.tar.gz"
local s3_key="mysql/full/${TIMESTAMP}.tar.gz"
log_info "[MySQL] 开始 xtrabackup 全量备份"
mkdir -p "${target}"
if ! mysql_ping; then
log_warn "[MySQL] 无法连接 MySQL,跳过 MySQL 备份"
return 1
fi
# xtrabackup --backup:连接 MySQL 并备份 InnoDB 数据文件
xtrabackup \
--backup \
--host="${MYSQL_HOST}" \
--port="${MYSQL_PORT}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--target-dir="${target}" \
--compress \
--compress-threads=2 \
${MYSQL_DATADIR:+--datadir="${MYSQL_DATADIR}"} \
2>&1 | tee "${target}/xtrabackup-backup.log"
# xtrabackup --prepare:回放 redo log,使备份一致(无需 MySQL 服务)
xtrabackup \
--prepare \
--target-dir="${target}" \
2>&1 | tee "${target}/xtrabackup-prepare.log"
# 打包(xtrabackup --compress 已压缩,这里仅归档)
log_info "[MySQL] 打包备份目录"
tar -czf "${archive}" -C "$(dirname "${target}")" "$(basename "${target}")"
# 更新 latest-full 引用(供增量备份使用)
echo "${target}" > "${BACKUP_DIR}/mysql/latest-full.txt"
# 清除上一次增量引用
rm -f "${BACKUP_DIR}/mysql/latest-inc.txt"
# 加密 + 上传
encrypt_and_upload "${archive}" "${s3_key}"
# 清理本地中间文件(保留原始目录供后续增量)
rm -f "${archive}"
# 清理 7 天前的全量备份目录
find "${BACKUP_DIR}/mysql/full" -maxdepth 1 -type d -mtime +7 \
-not -path "${BACKUP_DIR}/mysql/full" \
-exec rm -rf {} + 2>/dev/null || true
log_ok "[MySQL] 全量备份完成: ${target}"
}
# ──────────────────────────────────────────────────────
# 2. PostgreSQL 全量备份(pg_dump
# ──────────────────────────────────────────────────────
postgres_full_backup() {
log_info "[PostgreSQL] 开始 pg_dump 全量备份"
if ! pg_ping; then
log_warn "[PostgreSQL] 无法连接 PostgreSQL,跳过 PG 备份"
return 1
fi
# 支持多数据库(逗号分隔)
IFS=',' read -ra DBS <<< "${PG_DATABASES:-postgres}"
for DB in "${DBS[@]}"; do
DB="$(echo "${DB}" | tr -d ' ')"
local archive="${BACKUP_DIR}/postgres/full_${DB}_${TIMESTAMP}.pgdump"
local s3_key="postgres/full_${DB}_${TIMESTAMP}.pgdump"
log_info "[PostgreSQL] 备份数据库: ${DB}"
PGPASSWORD="${PG_PASSWORD:-}" pg_dump \
--host="${PG_HOST}" \
--port="${PG_PORT}" \
--username="${PG_USER:-postgres}" \
--format=custom \
--compress=6 \
--no-password \
"${DB}" > "${archive}"
encrypt_and_upload "${archive}" "${s3_key}"
rm -f "${archive}"
log_ok "[PostgreSQL] ${DB} 备份完成"
done
}
# ──────────────────────────────────────────────────────
# 3. Marzban SQLite 备份
# ──────────────────────────────────────────────────────
marzban_backup() {
local marzban_db="${MARZBAN_DB_PATH:-/var/lib/marzban/db.sqlite3}"
local archive="${BACKUP_DIR}/marzban/marzban_${TIMESTAMP}.sqlite3.gz"
local s3_key="marzban/marzban_${TIMESTAMP}.sqlite3.gz"
if [ ! -f "${marzban_db}" ]; then
log_warn "[Marzban] SQLite 文件不存在: ${marzban_db},跳过"
return 0
fi
log_info "[Marzban] 备份 SQLite: ${marzban_db}"
# 使用 sqlite3 online backup,避免损坏 WAL
sqlite3 "${marzban_db}" ".backup '${BACKUP_DIR}/marzban/marzban_${TIMESTAMP}.sqlite3'"
gzip -9 "${BACKUP_DIR}/marzban/marzban_${TIMESTAMP}.sqlite3"
encrypt_and_upload "${archive}" "${s3_key}"
rm -f "${archive}"
# 清理 30 天前的 SQLite 备份
find "${BACKUP_DIR}/marzban" -name "*.gz" -mtime +30 -delete 2>/dev/null || true
log_ok "[Marzban] SQLite 备份完成"
}
# ──────────────────────────────────────────────────────
# 执行
# ──────────────────────────────────────────────────────
mysql_full_backup || { log_error "[MySQL] 全量备份失败"; ERRORS=$(( ERRORS + 1 )); }
postgres_full_backup || { log_error "[PostgreSQL] 全量备份失败"; ERRORS=$(( ERRORS + 1 )); }
marzban_backup || { log_error "[Marzban] 备份失败"; ERRORS=$(( ERRORS + 1 )); }
ELAPSED=$(timer_elapsed "${START}")
DURATION=$(format_duration "${ELAPSED}")
if [ "${ERRORS}" -eq 0 ]; then
log_ok "========== 全量备份完成,耗时 ${DURATION} =========="
notify "INFO" "全量备份成功,耗时 ${DURATION}"
else
log_error "========== 全量备份完成(${ERRORS} 个失败),耗时 ${DURATION} =========="
notify "ERROR" "全量备份完成,但有 ${ERRORS} 个失败,请检查日志"
exit 1
fi
-194
View File
@@ -1,194 +0,0 @@
#!/usr/bin/env bash
# backup-inc.sh —— 每 15 分钟增量备份(RPO ≤ 15min)
# MySQLxtrabackup 增量 + binlog flush & 归档
# PostgreSQLpg_dump(轻量,一致性快照)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
START="$(timer_start)"
log_info "---------- 增量备份开始 ${TIMESTAMP} ----------"
ERRORS=0
# ──────────────────────────────────────────────────────
# 1. MySQL 增量备份(xtrabackup incremental
# ──────────────────────────────────────────────────────
mysql_incremental_backup() {
# 确定 basedir:优先用最近一次增量,若无则用最近全量
local basedir=""
if [ -f "${BACKUP_DIR}/mysql/latest-inc.txt" ]; then
basedir="$(cat "${BACKUP_DIR}/mysql/latest-inc.txt")"
elif [ -f "${BACKUP_DIR}/mysql/latest-full.txt" ]; then
basedir="$(cat "${BACKUP_DIR}/mysql/latest-full.txt")"
else
log_warn "[MySQL] 未找到基础备份,跳过增量(请先执行全量备份)"
return 1
fi
if [ ! -d "${basedir}" ]; then
log_warn "[MySQL] 基础备份目录不存在: ${basedir},跳过增量"
return 1
fi
local target="${BACKUP_DIR}/mysql/inc/${TIMESTAMP}"
local archive="${BACKUP_DIR}/mysql/inc/${TIMESTAMP}.tar.gz"
local s3_key="mysql/inc/${TIMESTAMP}.tar.gz"
log_info "[MySQL] xtrabackup 增量备份,basedir=${basedir}"
mkdir -p "${target}"
if ! mysql_ping; then
log_warn "[MySQL] 无法连接 MySQL,跳过增量"
return 1
fi
xtrabackup \
--backup \
--host="${MYSQL_HOST}" \
--port="${MYSQL_PORT}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--target-dir="${target}" \
--incremental-basedir="${basedir}" \
--compress \
--compress-threads=2 \
${MYSQL_DATADIR:+--datadir="${MYSQL_DATADIR}"} \
2>&1 | tee "${target}/xtrabackup.log"
tar -czf "${archive}" -C "$(dirname "${target}")" "$(basename "${target}")"
# 更新最新增量引用
echo "${target}" > "${BACKUP_DIR}/mysql/latest-inc.txt"
encrypt_and_upload "${archive}" "${s3_key}"
rm -f "${archive}"
# 清理 2 天前的增量目录(全量已上 S3,本地只保留最近供下一次增量)
find "${BACKUP_DIR}/mysql/inc" -maxdepth 1 -type d -mtime +2 \
-not -path "${BACKUP_DIR}/mysql/inc" \
-exec rm -rf {} + 2>/dev/null || true
log_ok "[MySQL] 增量备份完成: ${target}"
}
# ──────────────────────────────────────────────────────
# 2. MySQL Binlog 归档(FLUSH BINARY LOGS + 复制新 binlog
# ──────────────────────────────────────────────────────
mysql_binlog_backup() {
if ! mysql_ping; then
log_warn "[MySQL-binlog] 无法连接 MySQL,跳过 binlog 归档"
return 1
fi
log_info "[MySQL-binlog] FLUSH BINARY LOGS"
mysql \
--host="${MYSQL_HOST}" \
--port="${MYSQL_PORT}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
-e "FLUSH BINARY LOGS;" 2>/dev/null
# 从 MySQL 取 binlog 文件列表
local binlog_dir="${MYSQL_DATADIR:-/var/lib/mysql}"
local dest="${BACKUP_DIR}/mysql/binlog"
mkdir -p "${dest}"
# 使用 mysqlbinlog 工具读取当前活跃 binlog 之前的文件并归档
local binlog_index
binlog_index="$(mysql \
--host="${MYSQL_HOST}" \
--port="${MYSQL_PORT}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--batch --skip-column-names \
-e "SHOW BINARY LOGS;" 2>/dev/null | awk '{print $1}')"
if [ -z "${binlog_index}" ]; then
log_warn "[MySQL-binlog] 无法获取 binlog 列表(binlog 未开启?),跳过"
return 0
fi
# 找出尚未归档的 binlog 文件(非最新一个,最新的正在写入)
local count
count=$(echo "${binlog_index}" | wc -l)
# 跳过最后一个(正在写入)
local to_archive
to_archive=$(echo "${binlog_index}" | head -n $(( count - 1 )))
for binlog_file in ${to_archive}; do
local dest_file="${dest}/${binlog_file}.gz"
if [ -f "${dest_file}" ]; then
continue # 已归档
fi
log_info "[MySQL-binlog] 归档 ${binlog_file}"
# 使用 mysqlbinlog 以 raw 模式读取 + gzip 压缩
mysqlbinlog \
--host="${MYSQL_HOST}" \
--port="${MYSQL_PORT}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--read-from-remote-server \
--raw \
--result-file="${dest}/" \
"${binlog_file}" 2>/dev/null \
&& gzip -f "${dest}/${binlog_file}" \
&& encrypt_and_upload "${dest_file}" "mysql/binlog/${TIMESTAMP}_${binlog_file}.gz" \
&& rm -f "${dest_file}" \
|| log_warn "[MySQL-binlog] ${binlog_file} 归档失败,忽略"
done
# 清理 3 天前的本地 binlog 备份
find "${dest}" -name "*.gz" -mtime +3 -delete 2>/dev/null || true
log_ok "[MySQL-binlog] binlog 归档完成"
}
# ──────────────────────────────────────────────────────
# 3. PostgreSQL 增量备份(pg_dump15min 一次)
# ──────────────────────────────────────────────────────
postgres_incremental_backup() {
if ! pg_ping; then
log_warn "[PostgreSQL] 无法连接,跳过增量"
return 1
fi
IFS=',' read -ra DBS <<< "${PG_DATABASES:-postgres}"
for DB in "${DBS[@]}"; do
DB="$(echo "${DB}" | tr -d ' ')"
local archive="${BACKUP_DIR}/postgres/inc_${DB}_${TIMESTAMP}.pgdump"
local s3_key="postgres/inc/${DB}/${TIMESTAMP}.pgdump"
PGPASSWORD="${PG_PASSWORD:-}" pg_dump \
--host="${PG_HOST}" \
--port="${PG_PORT}" \
--username="${PG_USER:-postgres}" \
--format=custom \
--compress=6 \
--no-password \
"${DB}" > "${archive}"
encrypt_and_upload "${archive}" "${s3_key}"
rm -f "${archive}"
log_ok "[PostgreSQL] ${DB} 增量快照完成"
done
}
# ──────────────────────────────────────────────────────
# 执行
# ──────────────────────────────────────────────────────
mysql_incremental_backup || { log_error "[MySQL] 增量备份失败"; ERRORS=$(( ERRORS + 1 )); }
mysql_binlog_backup || { log_warn "[MySQL] binlog 归档失败"; }
postgres_incremental_backup || { log_error "[PG] 增量备份失败"; ERRORS=$(( ERRORS + 1 )); }
ELAPSED=$(timer_elapsed "${START}")
DURATION=$(format_duration "${ELAPSED}")
if [ "${ERRORS}" -eq 0 ]; then
log_ok "---------- 增量备份完成,耗时 ${DURATION} ----------"
else
log_error "---------- 增量备份完成(${ERRORS} 个失败),耗时 ${DURATION} ----------"
exit 1
fi
-306
View File
@@ -1,306 +0,0 @@
#!/usr/bin/env bash
# dr-drill.sh —— 月度灾备恢复演练
# 目标:RPO ≤ 15minRTO ≤ 4h
#
# 演练流程:
# 1. 从 S3 列出最新备份,验证时间戳(RPO 检查)
# 2. 下载最新全量备份,age 解密,验证完整性
# 3. 在临时 MySQL 容器中执行完整恢复,验证数据
# 4. 在临时 PostgreSQL 容器中执行恢复,验证数据
# 5. 记录总耗时,与 RTO 4h 对比
# 6. 生成报告,通过 Webhook 推送
#
# 演练使用独立 Docker 容器,不影响生产数据库
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
DRILL_ID="drill_$(date +%Y%m%d_%H%M%S)"
DRILL_DIR="/tmp/dr_drill_${DRILL_ID}"
REPORT_FILE="${DRILL_DIR}/report.txt"
RTO_TARGET_SECONDS=$(( 4 * 3600 )) # 4 小时
DRILL_START="$(timer_start)"
mkdir -p "${DRILL_DIR}"
# age 私钥(演练时必须可用)
AGE_PRIVATE_KEY_FILE="${AGE_PRIVATE_KEY_FILE:-/etc/age-private.txt}"
log_info "========== DR 演练开始 [${DRILL_ID}] =========="
# ---------- 报告工具 ----------
PASS_COUNT=0
FAIL_COUNT=0
check_pass() { PASS_COUNT=$(( PASS_COUNT + 1 )); log_ok "$*"; echo "PASS: $*" >> "${REPORT_FILE}"; }
check_fail() { FAIL_COUNT=$(( FAIL_COUNT + 1 )); log_error "$*"; echo "FAIL: $*" >> "${REPORT_FILE}"; }
section() { log_info "--- $* ---"; echo "" >> "${REPORT_FILE}"; echo "=== $* ===" >> "${REPORT_FILE}"; }
{
echo "Pangolin 灾备演练报告"
echo "演练 ID: ${DRILL_ID}"
echo "开始时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "目标: RPO ≤ 15min, RTO ≤ 4h"
echo ""
} > "${REPORT_FILE}"
# ──────────────────────────────────────────────────────
# 检查 1:RPO 验证(最新增量备份时间戳)
# ──────────────────────────────────────────────────────
section "RPO 验证(目标 ≤ 15min"
check_rpo() {
log_info "[RPO] 检查 S3 最新增量备份时间"
# 列出最新的增量备份文件
local latest_inc
latest_inc="$(AWS_PROFILE="${AWS_PROFILE}" aws s3 ls \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/mysql/inc/" \
--recursive \
| sort | tail -1 | awk '{print $1, $2}')"
if [ -z "${latest_inc}" ]; then
check_fail "S3 中未找到 MySQL 增量备份"
return 1
fi
log_info "[RPO] 最新增量备份: ${latest_inc}"
local last_date last_time
last_date=$(echo "${latest_inc}" | awk '{print $1}')
last_time=$(echo "${latest_inc}" | awk '{print $2}')
local last_ts
last_ts="$(date -d "${last_date} ${last_time}" +%s 2>/dev/null || date -j -f '%Y-%m-%d %H:%M:%S' "${last_date} ${last_time}" +%s)"
local now
now="$(date +%s)"
local gap=$(( now - last_ts ))
local gap_min=$(( gap / 60 ))
echo "最新增量备份距今: ${gap_min} 分钟" >> "${REPORT_FILE}"
if [ "${gap}" -le 900 ]; then # 900 秒 = 15 分钟
check_pass "RPO 达标: 最新备份距今 ${gap_min} 分钟 (≤ 15min)"
else
check_fail "RPO 未达标: 最新备份距今 ${gap_min} 分钟 (> 15min)"
fi
}
check_rpo || true
# ──────────────────────────────────────────────────────
# 检查 2:备份文件完整性验证
# ──────────────────────────────────────────────────────
section "备份文件完整性"
check_backup_integrity() {
if [ ! -f "${AGE_PRIVATE_KEY_FILE}" ]; then
check_fail "age 私钥不可用,跳过解密验证(设置 AGE_PRIVATE_KEY 环境变量)"
return 1
fi
log_info "[完整性] 下载并解密最新全量备份"
# 找到最新全量备份的 S3 key
local latest_full_key
latest_full_key="$(AWS_PROFILE="${AWS_PROFILE}" aws s3 ls \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/mysql/full/" \
| sort | tail -1 | awk '{print $4}')"
if [ -z "${latest_full_key}" ]; then
check_fail "S3 中未找到 MySQL 全量备份"
return 1
fi
local enc_file="${DRILL_DIR}/${latest_full_key}"
local dec_file="${enc_file%.age}"
log_info "[完整性] 下载: ${latest_full_key}"
AWS_PROFILE="${AWS_PROFILE}" aws s3 cp \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/mysql/full/${latest_full_key}" \
"${enc_file}"
log_info "[完整性] age 解密"
age --decrypt --identity "${AGE_PRIVATE_KEY_FILE}" -o "${dec_file}" "${enc_file}"
rm -f "${enc_file}"
# 验证 tar 归档完整性(不解压)
if tar -tzf "${dec_file}" > /dev/null 2>&1; then
check_pass "全量备份 tar.gz 归档完整"
else
check_fail "全量备份 tar.gz 归档损坏"
return 1
fi
echo "${dec_file}"
}
FULL_ARCHIVE="$(check_backup_integrity 2>/dev/null)" || FULL_ARCHIVE=""
# ──────────────────────────────────────────────────────
# 检查 3:MySQL 恢复验证(临时容器)
# ──────────────────────────────────────────────────────
section "MySQL 恢复验证"
MYSQL_DRILL_PORT=33306 # 避免与生产 3306 冲突
MYSQL_DRILL_CONTAINER="drill_mysql_${DRILL_ID}"
check_mysql_restore() {
if [ -z "${FULL_ARCHIVE}" ]; then
check_fail "全量备份不可用,跳过 MySQL 恢复验证"
return 1
fi
local mysql_restore_dir="${DRILL_DIR}/mysql_restore"
mkdir -p "${mysql_restore_dir}"
# 解压备份
log_info "[MySQL] 解压备份到临时目录"
tar -xzf "${FULL_ARCHIVE}" -C "${mysql_restore_dir}"
local backup_subdir
backup_subdir="$(ls -1 "${mysql_restore_dir}" | head -1)"
local target_dir="${mysql_restore_dir}/${backup_subdir}"
# xtrabackup --prepare(无需 MySQL 服务)
log_info "[MySQL] xtrabackup --prepare 验证"
if xtrabackup --prepare --target-dir="${target_dir}" 2>&1 | grep -q "completed OK"; then
check_pass "xtrabackup --prepare 成功(备份一致性验证通过)"
else
check_fail "xtrabackup --prepare 失败"
return 1
fi
# 启动临时 MySQL 容器(使用恢复的数据目录)
log_info "[MySQL] 启动临时 MySQL 容器(端口 ${MYSQL_DRILL_PORT}"
docker run -d \
--name "${MYSQL_DRILL_CONTAINER}" \
--mount "type=bind,src=${target_dir},dst=/var/lib/mysql" \
-e MYSQL_ROOT_PASSWORD=drill_tmp_pw \
-p "127.0.0.1:${MYSQL_DRILL_PORT}:3306" \
mysql:8.0 \
--skip-grant-tables 2>/dev/null || {
check_fail "临时 MySQL 容器启动失败(可能没有 Docker)"
return 1
}
# 等待 MySQL 就绪(最多 60 秒)
local wait=0
until mysql --host=127.0.0.1 --port="${MYSQL_DRILL_PORT}" \
--user=root --password=drill_tmp_pw \
-e "SELECT 1" > /dev/null 2>&1 || [ "${wait}" -ge 60 ]; do
sleep 2
wait=$(( wait + 2 ))
done
if mysql --host=127.0.0.1 --port="${MYSQL_DRILL_PORT}" \
--user=root --password=drill_tmp_pw \
-e "SHOW DATABASES;" > /dev/null 2>&1; then
check_pass "MySQL 从备份恢复后成功响应查询"
else
check_fail "MySQL 恢复后无法执行查询"
fi
# 停止并删除临时容器
docker stop "${MYSQL_DRILL_CONTAINER}" 2>/dev/null || true
docker rm -f "${MYSQL_DRILL_CONTAINER}" 2>/dev/null || true
rm -rf "${mysql_restore_dir}"
}
check_mysql_restore || true
# ──────────────────────────────────────────────────────
# 检查 4PostgreSQL 恢复验证
# ──────────────────────────────────────────────────────
section "PostgreSQL 恢复验证"
PG_DRILL_PORT=55432
PG_DRILL_CONTAINER="drill_pg_${DRILL_ID}"
check_pg_restore() {
if [ ! -f "${AGE_PRIVATE_KEY_FILE}" ]; then
check_fail "age 私钥不可用,跳过 PG 恢复验证"
return 1
fi
local first_db
IFS=',' read -ra DBS <<< "${PG_DATABASES:-postgres}"
first_db="${DBS[0]}"
first_db="$(echo "${first_db}" | tr -d ' ')"
log_info "[PostgreSQL] 下载最新 ${first_db} 备份"
local latest_key
latest_key="$(AWS_PROFILE="${AWS_PROFILE}" aws s3 ls \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/postgres/inc/${first_db}/" \
| sort | tail -1 | awk '{print $4}')"
if [ -z "${latest_key}" ]; then
check_fail "S3 中未找到 PostgreSQL 备份: ${first_db}"
return 1
fi
local enc_file="${DRILL_DIR}/pg_${latest_key}"
local dec_file="${enc_file%.age}"
AWS_PROFILE="${AWS_PROFILE}" aws s3 cp \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/postgres/inc/${first_db}/${latest_key}" \
"${enc_file}"
age --decrypt --identity "${AGE_PRIVATE_KEY_FILE}" -o "${dec_file}" "${enc_file}"
rm -f "${enc_file}"
# 用 pg_restore --list 验证备份结构(无需启动 PostgreSQL)
if pg_restore --list "${dec_file}" > /dev/null 2>&1; then
check_pass "PostgreSQL 备份结构验证通过 (${first_db})"
else
check_fail "PostgreSQL 备份文件损坏 (${first_db})"
fi
rm -f "${dec_file}"
}
check_pg_restore || true
# ──────────────────────────────────────────────────────
# 汇总报告
# ──────────────────────────────────────────────────────
section "RTO 验证(目标 ≤ 4h"
ELAPSED=$(timer_elapsed "${DRILL_START}")
DURATION=$(format_duration "${ELAPSED}")
echo "演练总耗时: ${ELAPSED}s (${DURATION})" >> "${REPORT_FILE}"
echo "RTO 目标: ${RTO_TARGET_SECONDS}s (4h)" >> "${REPORT_FILE}"
if [ "${ELAPSED}" -le "${RTO_TARGET_SECONDS}" ]; then
check_pass "RTO 达标: 演练耗时 ${DURATION} (≤ 4h)"
else
check_fail "RTO 未达标: 演练耗时 ${DURATION} (> 4h)"
fi
{
echo ""
echo "================="
echo "检查通过: ${PASS_COUNT}"
echo "检查失败: ${FAIL_COUNT}"
echo "================="
echo "结束时间: $(date '+%Y-%m-%d %H:%M:%S')"
if [ "${FAIL_COUNT}" -eq 0 ]; then
echo "结论: 演练通过 ✅"
else
echo "结论: 演练失败 ❌,请检查上方失败项"
fi
} >> "${REPORT_FILE}"
# 上传演练报告到 S3
if AWS_PROFILE="${AWS_PROFILE}" aws s3 cp "${REPORT_FILE}" \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/dr-reports/${DRILL_ID}.txt" 2>/dev/null; then
log_ok "演练报告已上传: s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/dr-reports/${DRILL_ID}.txt"
fi
# 打印报告
echo ""
cat "${REPORT_FILE}"
# 清理
rm -rf "${DRILL_DIR}"
# 通知
if [ "${FAIL_COUNT}" -eq 0 ]; then
log_ok "========== DR 演练通过 [通过: ${PASS_COUNT},失败: ${FAIL_COUNT},耗时: ${DURATION}] =========="
notify "INFO" "月度 DR 演练通过 | ${DRILL_ID} | 耗时 ${DURATION} | 通过 ${PASS_COUNT}"
else
log_error "========== DR 演练有 ${FAIL_COUNT} 项失败 [耗时: ${DURATION}] =========="
notify "ERROR" "月度 DR 演练失败 | ${DRILL_ID} | 失败 ${FAIL_COUNT} 项,请检查"
exit 1
fi
-110
View File
@@ -1,110 +0,0 @@
#!/usr/bin/env bash
# lib.sh —— 共用函数库,被所有备份脚本 source
# ---------- 日志 ----------
log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*"; }
log_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" >&2; }
log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; }
log_ok() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [OK] $*"; }
# ---------- 默认值 ----------
: "${BACKUP_DIR:=/backup}"
: "${MYSQL_HOST:=127.0.0.1}"
: "${MYSQL_PORT:=3306}"
: "${PG_HOST:=127.0.0.1}"
: "${PG_PORT:=5432}"
: "${BACKUP_AWS_REGION:=ap-southeast-1}"
: "${AWS_PROFILE:=backup}"
# ---------- 通知(Webhook,可选) ----------
notify() {
local level="$1"
local msg="$2"
log_info "[NOTIFY] ${level}: ${msg}"
if [ -n "${NOTIFY_WEBHOOK_URL:-}" ]; then
curl -fsS -X POST "${NOTIFY_WEBHOOK_URL}" \
-H 'Content-Type: application/json' \
-d "{\"level\":\"${level}\",\"msg\":$(printf '%s' "${msg}" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')}" \
--max-time 10 || true
fi
}
# ---------- age 加密 ----------
# 用法: age_encrypt <input_file> <output_file>
age_encrypt() {
local src="$1"
local dst="$2"
local pubkey_file="${AGE_PUBLIC_KEY_FILE:-/etc/age-public.txt}"
if [ ! -f "${pubkey_file}" ]; then
log_error "age 公钥文件不存在: ${pubkey_file}"
return 1
fi
age --encrypt --recipient "$(cat "${pubkey_file}")" -o "${dst}" "${src}"
}
# ---------- S3 上传 ----------
# 用法: s3_upload <local_file> <s3_path_suffix>
s3_upload() {
local src="$1"
local suffix="$2"
local dst="s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/${suffix}"
log_info "上传 $(basename "${src}")${dst}"
AWS_PROFILE="${AWS_PROFILE}" aws s3 cp \
--no-progress \
--storage-class STANDARD_IA \
"${src}" "${dst}"
log_ok "上传完成: ${dst}"
}
# ---------- 加密并上传 ----------
# 用法: encrypt_and_upload <input_file> <s3_suffix>
encrypt_and_upload() {
local src="$1"
local suffix="$2"
local enc_file="${BACKUP_DIR}/encrypt/$(basename "${src}").age"
mkdir -p "${BACKUP_DIR}/encrypt"
log_info "age 加密: ${src}"
age_encrypt "${src}" "${enc_file}"
s3_upload "${enc_file}" "${suffix}.age"
# 删除本地加密临时文件(原始文件由调用方管理)
rm -f "${enc_file}"
}
# ---------- MySQL 连接测试 ----------
mysql_ping() {
mysql \
--host="${MYSQL_HOST}" \
--port="${MYSQL_PORT}" \
--user="${MYSQL_USER:-root}" \
--password="${MYSQL_PASSWORD}" \
--connect-timeout=5 \
-e "SELECT 1" > /dev/null 2>&1
}
# ---------- PostgreSQL 连接测试 ----------
pg_ping() {
PGPASSWORD="${PG_PASSWORD:-}" pg_isready \
--host="${PG_HOST}" \
--port="${PG_PORT}" \
--username="${PG_USER:-postgres}" \
--quiet 2>&1
}
# ---------- 计时工具 ----------
timer_start() { date +%s; }
timer_elapsed() {
local start="$1"
local end
end=$(date +%s)
echo $(( end - start ))
}
format_duration() {
local secs="$1"
local h=$(( secs / 3600 ))
local m=$(( (secs % 3600) / 60 ))
local s=$(( secs % 60 ))
printf '%dh %dm %ds' "${h}" "${m}" "${s}"
}
-187
View File
@@ -1,187 +0,0 @@
#!/usr/bin/env bash
# restore.sh —— 灾备恢复脚本
# 用途:从 S3 下载 → age 解密 → 恢复 MySQL / PostgreSQL
# 使用方式:
# restore.sh mysql full 20240101_020000 # 恢复 MySQL 指定全量
# restore.sh mysql inc 20240101_021500 # 在全量基础上应用增量
# restore.sh postgres blog 20240101_021500
# restore.sh marzban 20240101_020000
#
# 注意:MySQL 恢复需要先停止 MySQL 服务(由运维手动执行)
# 此脚本只做"下载 + 解密 + 准备",不自动重启 MySQL 服务
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
# age 私钥(恢复时必须提供)
AGE_PRIVATE_KEY_FILE="${AGE_PRIVATE_KEY_FILE:-/etc/age-private.txt}"
if [ ! -f "${AGE_PRIVATE_KEY_FILE}" ]; then
log_error "age 私钥文件不存在: ${AGE_PRIVATE_KEY_FILE}"
log_error "恢复需要私钥。请将私钥文件挂载到容器,或设置 AGE_PRIVATE_KEY 环境变量"
exit 1
fi
RESTORE_DIR="${RESTORE_DIR:-/restore}"
mkdir -p "${RESTORE_DIR}"
# ---------- age 解密 ----------
age_decrypt() {
local src="$1"
local dst="$2"
age --decrypt --identity "${AGE_PRIVATE_KEY_FILE}" -o "${dst}" "${src}"
}
# ---------- 从 S3 下载并解密 ----------
download_and_decrypt() {
local s3_key="$1"
local local_enc="${RESTORE_DIR}/$(basename "${s3_key}")"
local local_dec="${local_enc%.age}"
log_info "从 S3 下载: s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/${s3_key}.age"
AWS_PROFILE="${AWS_PROFILE}" aws s3 cp \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/${s3_key}.age" \
"${local_enc}"
log_info "age 解密: ${local_enc}"
age_decrypt "${local_enc}" "${local_dec}"
rm -f "${local_enc}"
echo "${local_dec}"
}
# ──────────────────────────────────────────────────────
# MySQL 恢复
# ──────────────────────────────────────────────────────
restore_mysql_full() {
local timestamp="$1"
local s3_key="mysql/full/${timestamp}.tar.gz"
local restore_base="${RESTORE_DIR}/mysql"
mkdir -p "${restore_base}"
log_info "[MySQL] 下载并解密全量备份: ${timestamp}"
local archive
archive="$(download_and_decrypt "${s3_key}")"
log_info "[MySQL] 解压: ${archive}"
tar -xzf "${archive}" -C "${restore_base}"
rm -f "${archive}"
local backup_dir="${restore_base}/${timestamp}"
log_info "[MySQL] xtrabackup --prepare(应用增量前的全量 prepare"
xtrabackup --prepare --apply-log-only --target-dir="${backup_dir}"
log_ok "[MySQL] 全量备份已解压并 prepare 到: ${backup_dir}"
echo "${backup_dir}"
}
restore_mysql_inc() {
local base_dir="$1"
local timestamp="$2"
local s3_key="mysql/inc/${timestamp}.tar.gz"
local restore_base="${RESTORE_DIR}/mysql"
log_info "[MySQL] 下载并解密增量备份: ${timestamp}"
local archive
archive="$(download_and_decrypt "${s3_key}")"
tar -xzf "${archive}" -C "${restore_base}"
rm -f "${archive}"
local inc_dir="${restore_base}/${timestamp}"
log_info "[MySQL] xtrabackup --prepare(应用增量 ${timestamp}"
xtrabackup --prepare --apply-log-only \
--target-dir="${base_dir}" \
--incremental-dir="${inc_dir}"
rm -rf "${inc_dir}"
log_ok "[MySQL] 增量 ${timestamp} 已应用到 ${base_dir}"
}
restore_mysql_finalize() {
local base_dir="$1"
log_info "[MySQL] xtrabackup --prepare(最终 redo log 回放)"
xtrabackup --prepare --target-dir="${base_dir}"
log_ok "[MySQL] 备份已可用,恢复步骤:"
log_ok " 1. 停止 MySQLsystemctl stop mysql"
log_ok " 2. 清空数据目录:rm -rf /var/lib/mysql/*"
log_ok " 3. 复制备份:xtrabackup --copy-back --target-dir=${base_dir}"
log_ok " 4. 修改权限:chown -R mysql:mysql /var/lib/mysql"
log_ok " 5. 启动 MySQLsystemctl start mysql"
}
# ──────────────────────────────────────────────────────
# PostgreSQL 恢复
# ──────────────────────────────────────────────────────
restore_postgres() {
local db="$1"
local timestamp="$2"
local s3_key="postgres/inc/${db}/${timestamp}.pgdump"
local restore_target="${PG_RESTORE_TARGET:-${db}_restored}"
log_info "[PostgreSQL] 下载并解密: ${db} @ ${timestamp}"
local dump_file
dump_file="$(download_and_decrypt "${s3_key}")"
log_info "[PostgreSQL] 恢复数据库: ${restore_target}"
PGPASSWORD="${PG_PASSWORD:-}" psql \
--host="${PG_HOST}" \
--port="${PG_PORT}" \
--username="${PG_USER:-postgres}" \
-c "CREATE DATABASE \"${restore_target}\";" 2>/dev/null || true
PGPASSWORD="${PG_PASSWORD:-}" pg_restore \
--host="${PG_HOST}" \
--port="${PG_PORT}" \
--username="${PG_USER:-postgres}" \
--dbname="${restore_target}" \
--no-password \
--verbose \
"${dump_file}"
rm -f "${dump_file}"
log_ok "[PostgreSQL] 恢复完成: ${restore_target}"
}
# ──────────────────────────────────────────────────────
# 主入口
# ──────────────────────────────────────────────────────
case "${1:-}" in
mysql)
case "${2:-}" in
full)
base_dir="$(restore_mysql_full "${3}")"
restore_mysql_finalize "${base_dir}"
;;
inc)
if [ -z "${4:-}" ]; then
log_error "用法: restore.sh mysql inc <base_timestamp> <inc_timestamp>"
exit 1
fi
base_dir="$(restore_mysql_full "${3}")"
restore_mysql_inc "${base_dir}" "${4}"
restore_mysql_finalize "${base_dir}"
;;
*)
echo "用法: restore.sh mysql {full|inc} <timestamp> [inc_timestamp]"
exit 1
;;
esac
;;
postgres)
restore_postgres "${2}" "${3}"
;;
marzban)
marzban_archive="$(download_and_decrypt "marzban/marzban_${2}.sqlite3.gz")"
log_ok "[Marzban] 解密文件: ${marzban_archive}"
;;
*)
echo "用法: restore.sh {mysql|postgres|marzban} ..."
echo " restore.sh mysql full <timestamp>"
echo " restore.sh mysql inc <full_timestamp> <inc_timestamp>"
echo " restore.sh postgres <db_name> <timestamp>"
echo " restore.sh marzban <timestamp>"
exit 1
;;
esac
-167
View File
@@ -1,167 +0,0 @@
#!/usr/bin/env bash
# verify-rpo-rto.sh —— 实时 RPO/RTO 状态检查
# 用法:
# verify-rpo-rto.sh # 完整检查并打印结果
# verify-rpo-rto.sh --status-only # 仅打印摘要(容器启动时调用)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
STATUS_ONLY="${1:-}"
log_info "RPO/RTO 状态检查"
RPO_TARGET_SECONDS=900 # 15 分钟
RTO_TARGET_HOURS=4
PASS=0
WARN=0
FAIL=0
check() {
local status="$1"
local msg="$2"
case "${status}" in
pass) PASS=$(( PASS + 1 )); log_ok "${msg}" ;;
warn) WARN=$(( WARN + 1 )); log_warn " ⚠️ ${msg}" ;;
fail) FAIL=$(( FAIL + 1 )); log_error "${msg}" ;;
esac
}
# ──────────────────────────────────────────────────────
# 1. 检查 S3 最新增量备份的时间
# ──────────────────────────────────────────────────────
check_latest_backup_age() {
local service="$1"
local s3_prefix="$2"
local latest
latest="$(AWS_PROFILE="${AWS_PROFILE}" aws s3 ls \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/${s3_prefix}/" \
--recursive 2>/dev/null \
| sort | tail -1 | awk '{print $1, $2}' || echo "")"
if [ -z "${latest}" ]; then
check fail "${service}: S3 中无备份"
return
fi
local last_date last_time
last_date=$(echo "${latest}" | awk '{print $1}')
last_time=$(echo "${latest}" | awk '{print $2}')
local last_ts
last_ts="$(date -d "${last_date} ${last_time}" +%s 2>/dev/null \
|| date -j -f '%Y-%m-%d %H:%M:%S' "${last_date} ${last_time}" +%s 2>/dev/null \
|| echo 0)"
local now
now=$(date +%s)
local gap=$(( now - last_ts ))
local gap_min=$(( gap / 60 ))
if [ "${gap}" -le "${RPO_TARGET_SECONDS}" ]; then
check pass "${service}: 最新备份距今 ${gap_min}min (RPO ≤ 15min ✓)"
elif [ "${gap}" -le $(( RPO_TARGET_SECONDS * 2 )) ]; then
check warn "${service}: 最新备份距今 ${gap_min}min (接近 RPO 限制)"
else
check fail "${service}: 最新备份距今 ${gap_min}min (超出 RPO 15min 限制)"
fi
}
# ──────────────────────────────────────────────────────
# 2. 检查 S3 最新全量备份时间(应为 < 25 小时)
# ──────────────────────────────────────────────────────
check_full_backup_freshness() {
local service="$1"
local s3_prefix="$2"
local latest
latest="$(AWS_PROFILE="${AWS_PROFILE}" aws s3 ls \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/${s3_prefix}/" \
2>/dev/null | sort | tail -1 | awk '{print $1, $2}' || echo "")"
if [ -z "${latest}" ]; then
check fail "${service} 全量: S3 中无全量备份"
return
fi
local last_date last_time
last_date=$(echo "${latest}" | awk '{print $1}')
last_time=$(echo "${latest}" | awk '{print $2}')
local last_ts
last_ts="$(date -d "${last_date} ${last_time}" +%s 2>/dev/null \
|| date -j -f '%Y-%m-%d %H:%M:%S' "${last_date} ${last_time}" +%s 2>/dev/null \
|| echo 0)"
local now
now=$(date +%s)
local gap_hours=$(( (now - last_ts) / 3600 ))
if [ "${gap_hours}" -le 25 ]; then
check pass "${service} 全量: 最近 ${gap_hours}h 内有全量备份"
else
check fail "${service} 全量: 上次全量备份距今 ${gap_hours}h (> 25h)"
fi
}
# ──────────────────────────────────────────────────────
# 3. 检查 DR 演练报告(最新演练是否在 35 天内)
# ──────────────────────────────────────────────────────
check_dr_drill_freshness() {
local latest
latest="$(AWS_PROFILE="${AWS_PROFILE}" aws s3 ls \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/dr-reports/" \
2>/dev/null | sort | tail -1 | awk '{print $1, $2}' || echo "")"
if [ -z "${latest}" ]; then
check warn "DR 演练: S3 中无演练报告(首次部署?)"
return
fi
local last_date last_time
last_date=$(echo "${latest}" | awk '{print $1}')
last_time=$(echo "${latest}" | awk '{print $2}')
local last_ts
last_ts="$(date -d "${last_date} ${last_time}" +%s 2>/dev/null \
|| date -j -f '%Y-%m-%d %H:%M:%S' "${last_date} ${last_time}" +%s 2>/dev/null \
|| echo 0)"
local now
now=$(date +%s)
local gap_days=$(( (now - last_ts) / 86400 ))
if [ "${gap_days}" -le 35 ]; then
check pass "DR 演练: 上次演练距今 ${gap_days}"
else
check warn "DR 演练: 上次演练距今 ${gap_days} 天 (> 35 天,请检查)"
fi
}
# ──────────────────────────────────────────────────────
# 执行检查
# ──────────────────────────────────────────────────────
check_latest_backup_age "MySQL-增量" "mysql/inc"
check_latest_backup_age "PG-增量" "postgres/inc"
check_full_backup_freshness "MySQL" "mysql/full"
check_full_backup_freshness "PostgreSQL" "postgres/full"
check_dr_drill_freshness
# ──────────────────────────────────────────────────────
# 汇总
# ──────────────────────────────────────────────────────
echo ""
log_info "RPO/RTO 检查汇总"
log_info " 通过: ${PASS} 警告: ${WARN} 失败: ${FAIL}"
log_info " RPO 目标: ≤ 15min | RTO 目标: ≤ ${RTO_TARGET_HOURS}h"
if [ "${FAIL}" -gt 0 ]; then
log_error "状态: 不达标 ❌"
exit 1
elif [ "${WARN}" -gt 0 ]; then
log_warn "状态: 警告 ⚠️"
exit 0
else
log_ok "状态: 达标 ✅"
exit 0
fi
+3 -3
View File
@@ -41,11 +41,11 @@ ssh racknerd 'cd /root/bootstrap && bash init.sh'
- **节点侧**`pangolin-monitor`(本机 systemd timer,每 `MONITOR_INTERVAL_MIN` 分钟):
采 CPU/内存/磁盘、受监控 systemd 单元存活(`MONITOR_UNITS`)、出网连通;
**越界/掉线/断网 → 🔴 必报;正常 → 🟢**(心跳是否推送由 `MONITOR_SEND_OK_PULSE` 控制)。
- **死信(不上报/宕机)**:节点宕了自己报不了,需在**常在线主机(EC2)**跑 `deadman-watch.sh`
主动探活补这层:
- **死信(不上报/宕机)**:节点宕了自己报不了,需在**任意一台常在线主机**(另一台节点 /
家里服务器 / 监控机)跑 `deadman-watch.sh` 主动探活补这层:
```bash
# 在 EC2
# 在那台常在线主机
sudo install -m 755 deploy/bootstrap/monitor/deadman-watch.sh /usr/local/bin/deadman-watch
sudo cp deploy/bootstrap/deadman.env.example /etc/pangolin-deadman.env && sudo vi /etc/pangolin-deadman.env
# 每 3 分钟探一次(用户 cron 即可,无需 root)
+4 -3
View File
@@ -1,13 +1,14 @@
#!/usr/bin/env bash
# deadman-watch —— 死信守望。放在【常在线主机】(如 EC2)上,主动 TCP 探活各节点端口;
# 连续失败到阈值 → 告警(节点宕机/不上报时,节点自己报不了,这层补上)。恢复也通知。
# deadman-watch —— 死信守望。放在【任意一台常在线主机】(另一台节点 / 家里服务器 / 监控机)
# 上,主动 TCP 探活各节点端口;连续失败到阈值 → 告警(节点宕机/不上报时自己报不了,
# 这层补上)。恢复也通知。
#
# 配置 /etc/pangolin-deadman.env:
# TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=...
# NODES="racknerd-la=107.172.55.251:443 tokyo=1.2.3.4:443" # name=host:port 空格分隔
# FAIL_THRESHOLD=2 # 连续失败几次才告警(去抖)
#
# EC2 上每 3 分钟跑一次(cron,用户态即可):
# 在该常在线主机上每 3 分钟跑一次(cron,用户态即可):
# */3 * * * * /usr/local/bin/deadman-watch >/dev/null 2>&1
set -uo pipefail
-3
View File
@@ -1,3 +0,0 @@
# Cloudflare DNS-01 凭据(渲染为 deploy/secrets/cloudflare.ini,权限 600,gitignore)。
# Token 需具备目标 zone 的 Zone:DNS:Edit 权限。
dns_cloudflare_api_token = __CF_API_TOKEN__
-99
View File
@@ -1,99 +0,0 @@
# 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 后端)。
# 自定义镜像 = 官方 nginx + certbot,容器自己续签自己用的证书(无需宿主 cron / docker socket)。
edge:
# 镜像由脚本用 `docker build -t pangolin-edge:local ./edge` 预先构建
# (宿主 docker-compose 版本不支持 compose build)。
image: pangolin-edge:local
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 # rw:certbot 续期写入,nginx 读取
- ./secrets/cloudflare.ini:/etc/cloudflare.ini:ro # DNS-01 续期凭据
- /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 -p pangolin run --rm certbot certonly --dns-cloudflare ...
# 日常【自动续期由 edge 容器自身完成】(见 edge/pangolin-entrypoint.sh),无需本服务。
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"]
# ──────────────────────────────────────────────────────────────────────────
# 备份与灾备(xtrabackup 全量 + binlog 增量 + age 加密 + S3 异地)
# 使用方法:
# 1. cp backup/backup.env.example secrets/backup.env && vi secrets/backup.env
# 2. docker build -t pangolin-backup:local ./backup
# 3. docker-compose -p pangolin up -d backup
# 注意:
# - 使用 network_mode: host,可访问 127.0.0.1 上的 MySQL/PostgreSQL
# - MySQL 数据目录挂载为只读(xtrabackup 需要);若 MySQL 在 Docker 卷中,
# 修改 MYSQL_DATADIR_HOST 指向实际卷路径(如 /var/lib/docker/volumes/jiu_jiu_mysql_data/_data
# - /opt/backup 是宿主备份暂存目录,重建容器后数据不丢失
# - age 私钥不存服务器;如需 DR 演练解密恢复,临时注入 AGE_PRIVATE_KEY 环境变量
# ──────────────────────────────────────────────────────────────────────────
backup:
# 镜像由脚本预先构建:docker build -t pangolin-backup:local ./backup
image: pangolin-backup:local
container_name: pangolin-backup
restart: unless-stopped
network_mode: host
env_file:
- ./secrets/backup.env
volumes:
# 宿主备份暂存目录(全量 / 增量 / binlog 解密临时文件)
- /opt/backup:/backup
# MySQL 数据目录(只读挂载供 xtrabackup)
# 若 MySQL 在 Docker 卷内,替换为实际路径:
# - /var/lib/docker/volumes/<volume_name>/_data:/var/lib/mysql:ro
- ${MYSQL_DATADIR_HOST:-/var/lib/mysql}:/var/lib/mysql:ro
# Marzban SQLite 数据目录(只读)
- ${MARZBAN_DATA_HOST:-/var/lib/marzban}:/var/lib/marzban:ro
healthcheck:
test: ["CMD", "pgrep", "cron"]
interval: 5m
timeout: 10s
retries: 3
# 新节点用的 REALITY —— 本台不启用(REALITY 仍由 Marzban 提供)
xray:
image: ghcr.io/xtls/xray-core:26.3.27
container_name: pangolin-xray
profiles: ["newnode"]
restart: unless-stopped
ports:
- "127.0.0.1:11443:11443"
volumes:
- ./xray/config.json:/etc/xray/config.json:ro
command: ["run", "-c", "/etc/xray/config.json"]
-10
View File
@@ -1,10 +0,0 @@
# pangolin-edge:官方 nginx + certbot(DNS-01/Cloudflare),让分发层容器自己续签自己用的证书。
FROM nginx:1.27-alpine
# certbot + Cloudflare DNS 插件(EC2 实测:certbot 3.0.1)
RUN apk add --no-cache certbot certbot-dns-cloudflare
COPY pangolin-entrypoint.sh /usr/local/bin/pangolin-entrypoint.sh
RUN chmod +x /usr/local/bin/pangolin-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/pangolin-entrypoint.sh"]
-37
View File
@@ -1,37 +0,0 @@
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;
}
-87
View File
@@ -1,87 +0,0 @@
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;
}
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签后返回 Flutter index.html
location ~ ^/product/ {
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 路由会把 /product/:id 重写为 /app/product/:idbase-href=/app/
# 分享此 URL 时微信爬虫也需要 OG 标签 → 去掉 /app 前缀后转发后端
location ~ ^/app/product/ {
rewrite ^/app(/product/.+)$ $1 break;
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;
}
# 桌面客户端安装包下载(Windows .exe / macOS .zip
location /downloads/ {
alias /opt/jiu/downloads/;
add_header Content-Disposition "attachment";
add_header Cache-Control "no-cache";
autoindex off;
}
# 营销站点兜底(根路径及所有其他路径)
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
@@ -1,26 +0,0 @@
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
@@ -1,4 +0,0 @@
# 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
@@ -1,16 +0,0 @@
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
@@ -1,41 +0,0 @@
# 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;
-21
View File
@@ -1,21 +0,0 @@
#!/bin/sh
# pangolin-edge 入口:前台跑 nginx,后台跑证书续期(续期后在【本容器内】热重载 nginx)。
# 续期 = certbot renew(DNS-01/Cloudflare),无需 docker socket / 宿主 cron / 跨容器信号。
set -e
# 后台续期循环:每 12h 检查一次;有证书续期则热重载,无则什么都不做。
(
# 启动后先等一会,避免与 nginx 启动抢资源
sleep 60
while true; do
if certbot renew --quiet --deploy-hook "nginx -s reload"; then
:
else
echo "[pangolin] certbot renew 失败,下个周期重试" >&2
fi
sleep 12h
done
) &
# 前台:沿用官方入口脚本(会执行 /docker-entrypoint.d/*)再启动 nginx
exec /docker-entrypoint.sh nginx -g 'daemon off;'
-20
View File
@@ -1,20 +0,0 @@
# 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;
}
}
-65
View File
@@ -1,65 +0,0 @@
#!/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
rollback() {
echo "==> !! 自动回滚:停 edge,恢复宿主 nginx" >&2
docker-compose -p pangolin stop edge >/dev/null 2>&1 || true
systemctl enable nginx >/dev/null 2>&1 || true
systemctl start nginx || echo " !! 宿主 nginx 启动失败,请手动 systemctl start nginx" >&2
echo " 已回滚到宿主 nginx。" >&2
exit 1
}
echo "==> [2/4] 停用宿主 nginx 并起 edge 容器(交接窗口,秒级)"
systemctl stop nginx
systemctl disable nginx 2>/dev/null || true
docker build -t pangolin-edge:local ./edge || { echo " edge 镜像构建失败" >&2; rollback; }
if ! docker-compose -p pangolin up -d edge singbox; then
echo " edge 启动失败" >&2; rollback
fi
echo "==> [3/4] 等待并健康检查"
sleep 2
# edge 容器必须在跑
if [[ "$(docker inspect -f '{{.State.Running}}' pangolin-edge 2>/dev/null)" != "true" ]]; then
echo " edge 容器未运行" >&2; docker logs pangolin-edge 2>&1 | tail -10; rollback
fi
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 " 部分检查未通过" >&2; rollback
fi
-47
View File
@@ -1,47 +0,0 @@
#!/usr/bin/env bash
# deploy.sh —— 幂等部署(免 root)。CI 与手动均可。
# - 总是确保 singbox(Hy2)在跑;
# - edge:若宿主 nginx 仍在(未切换)则只校验配置、不启动(避免抢 80/443);
# 宿主 nginx 已停(已切换)则正常拉起。
# 首次 nginx 切换由 cutover.sh(需 root 一次)完成。
set -euo pipefail
cd "$(dirname "$0")/.." # -> deploy/
EDGE_IMAGE="nginx:1.27-alpine"
echo "==> [1/4] 生成/复用密钥与证书"
./scripts/gen-secrets.sh
echo "==> [2/4] 渲染配置"
# shellcheck disable=SC1091
source ./secrets/pangolin.env
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
}
if systemctl is-active --quiet nginx 2>/dev/null; then
echo " 宿主 nginx 仍在运行 → 仅校验 edge 配置,不启动(切换请跑 cutover.sh)"
edge_validate && echo " edge 配置校验通过 ✅"
else
echo " 宿主 nginx 未运行 → 构建并拉起 edge"
edge_validate
docker build -t pangolin-edge:local ./edge
docker-compose -p pangolin up -d edge
docker-compose -p pangolin ps edge
fi
./scripts/print-clients.sh
echo "==> 完成 ✅"
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env bash
# gen-secrets.sh —— 幂等生成本台所需密钥/证书,持久化到 ./secrets/(700,gitignore)。
# - pangolin.env : HY2_PASSWORD
# - hy2/hy2.crt,hy2.key : Hysteria2 自签证书(ECDSA)
# - cloudflare.ini : 由 $CF_API_TOKEN 渲染(证书续期用),600
# - backup/age-keypair.txt : age 加密密钥对(私钥请离线保存后从服务器删除)
# - backup.env : 备份配置模板(首次生成,需手动填写真实值)
set -euo pipefail
cd "$(dirname "$0")/.." # -> deploy/
SECRETS_DIR="./secrets"
ENV_FILE="${SECRETS_DIR}/pangolin.env"
HY2_DIR="${SECRETS_DIR}/hy2"
MASQ_CN="www.bing.com"
mkdir -p "$HY2_DIR"
chmod 700 "$SECRETS_DIR"
# Hy2 口令
if [[ -f "$ENV_FILE" ]]; then
echo "[gen-secrets] $ENV_FILE 已存在,跳过"
else
HY2_PASSWORD="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-24)"
umask 177
printf 'HY2_PASSWORD=%s\n' "$HY2_PASSWORD" > "$ENV_FILE"
echo "[gen-secrets] 已写入 $ENV_FILE"
fi
# Hy2 自签证书
if [[ -f "${HY2_DIR}/hy2.crt" && -f "${HY2_DIR}/hy2.key" ]]; then
echo "[gen-secrets] Hy2 证书已存在,跳过"
else
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}"
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
# ──────────────────────────────────────────────────────────
# age 备份加密密钥对
# 首次生成后:
# 1. 将 age-keypair.txt 里的私钥(AGE-SECRET-KEY-...)离线保存
# 2. 从服务器删除私钥(只保留公钥供加密)
# 3. 将公钥填入 secrets/backup.env 的 AGE_PUBLIC_KEY 字段
# ──────────────────────────────────────────────────────────
BACKUP_DIR="${SECRETS_DIR}/backup"
AGE_KEYPAIR="${BACKUP_DIR}/age-keypair.txt"
mkdir -p "${BACKUP_DIR}"
chmod 700 "${BACKUP_DIR}"
if [[ -f "${AGE_KEYPAIR}" ]]; then
echo "[gen-secrets] age 密钥对已存在,跳过 (${AGE_KEYPAIR})"
else
# 检查 age-keygen 是否可用(需要先在容器内或本机安装)
if command -v age-keygen > /dev/null 2>&1; then
umask 177
age-keygen 2>&1 | tee "${AGE_KEYPAIR}"
chmod 600 "${AGE_KEYPAIR}"
AGE_PUB="$(grep '^# public key:' "${AGE_KEYPAIR}" | awk '{print $NF}')"
echo ""
echo "[gen-secrets] ╔═══════════════════════════════════════════════════╗"
echo "[gen-secrets] ║ age 密钥对已生成: ${AGE_KEYPAIR}"
echo "[gen-secrets] ║ 公钥: ${AGE_PUB}"
echo "[gen-secrets] ║"
echo "[gen-secrets] ║ ⚠️ 重要:请立即离线备份私钥,然后从服务器删除:"
echo "[gen-secrets] ║ grep 'AGE-SECRET' ${AGE_KEYPAIR} # 私钥"
echo "[gen-secrets] ║ rm ${AGE_KEYPAIR} # 服务器上删除"
echo "[gen-secrets] ╚═══════════════════════════════════════════════════╝"
echo ""
else
echo "[gen-secrets] age-keygen 未安装,跳过密钥对生成"
echo "[gen-secrets] 请在备份容器内执行: age-keygen | tee ${AGE_KEYPAIR}"
fi
fi
# 备份配置模板(首次生成,需手动填写)
BACKUP_ENV="${SECRETS_DIR}/backup.env"
if [[ -f "${BACKUP_ENV}" ]]; then
echo "[gen-secrets] ${BACKUP_ENV} 已存在,跳过"
else
cp ./backup/backup.env.example "${BACKUP_ENV}"
chmod 600 "${BACKUP_ENV}"
echo "[gen-secrets] 已生成备份配置模板: ${BACKUP_ENV}"
echo "[gen-secrets] ⚠️ 请编辑此文件并填写真实 S3 / MySQL / PostgreSQL / age 配置"
fi
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
# 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
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)"
HOST="$(curl -s -H "X-aws-ec2-metadata-token: ${TOKEN}" http://169.254.169.254/latest/meta-data/public-ipv4 || true)"
fi
[[ -z "$HOST" ]] && HOST="YOUR_SERVER_IP"
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 自有线路导入信息(生成于 $(date -u +%FT%TZ))。含敏感信息,勿外泄。
[Hysteria2] —— 晚高峰加速线(自签证书,客户端需允许 insecure)
${HY2_LINK}
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 ~/pangolin/secrets/clients.txt 取用)"
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# rollback.sh —— 一键回滚:停 pangolin-edge 容器,恢复宿主 nginx(需 root)。
# sudo bash /home/ec2-user/pangolin/scripts/rollback.sh
# 适用:切换失败、或切换后发现问题,要回到"宿主 nginx 接管 443/80"的原状态。
# 注:singbox(Hy2/UDP443)与宿主 nginx 不冲突,默认保留运行;如需一并停,见文档。
set -euo pipefail
if [[ "$(id -u)" -ne 0 ]]; then
echo "请以 root 运行:sudo bash $0" >&2
exit 1
fi
echo "==> [1/3] 停 edge 容器(释放 80/443)"
# 必须先停容器,否则宿主 nginx 会因端口占用起不来
docker-compose -p pangolin stop edge 2>/dev/null || docker stop pangolin-edge 2>/dev/null || true
echo "==> [2/3] 恢复宿主 nginx"
systemctl enable nginx >/dev/null 2>&1 || true
systemctl start nginx
systemctl is-active --quiet nginx && echo " 宿主 nginx 已启动" || { echo " !! 启动失败,见文档排查"; exit 1; }
echo "==> [3/3] 健康检查"
sleep 1
fail=0
echo | timeout 10 openssl s_client -connect 127.0.0.1:443 -servername www.cloudflare.com >/dev/null 2>&1 \
&& echo " [REALITY] 443 握手 OK" || { echo " [REALITY] 握手失败"; fail=1; }
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
[[ "$fail" == "0" ]] && echo "==> 回滚完成 ✅ 已回到宿主 nginx" \
|| { echo "==> ⚠️ 已回到宿主 nginx 但检查未全过,见文档"; exit 1; }
-24
View File
@@ -1,24 +0,0 @@
{
"log": { "level": "warn", "timestamp": true },
"inbounds": [
{
"type": "hysteria2",
"tag": "hy2-in",
"listen": "::",
"listen_port": 443,
"users": [
{ "name": "phase0", "password": "__HY2_PASSWORD__" }
],
"masquerade": "https://www.bing.com",
"tls": {
"enabled": true,
"alpn": ["h3"],
"certificate_path": "/etc/sing-box/cert/hy2.crt",
"key_path": "/etc/sing-box/cert/hy2.key"
}
}
],
"outbounds": [
{ "type": "direct", "tag": "direct" }
]
}
-36
View File
@@ -1,36 +0,0 @@
{
"log": { "loglevel": "warning" },
"inbounds": [
{
"tag": "vless-reality-in",
"listen": "0.0.0.0",
"port": 11443,
"protocol": "vless",
"settings": {
"clients": [
{ "id": "__UUID__", "flow": "xtls-rprx-vision" }
],
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"show": false,
"dest": "www.apple.com:443",
"xver": 0,
"serverNames": ["www.apple.com"],
"privateKey": "__REALITY_PRIVATE_KEY__",
"shortIds": ["__REALITY_SHORT_ID__"]
}
},
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls", "quic"]
}
}
],
"outbounds": [
{ "protocol": "freedom", "tag": "direct" }
]
}