From bf81c786b2518bb87b2804dccebc49ee73b82a71 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 18 Jun 2026 06:55:25 +0800 Subject: [PATCH] =?UTF-8?q?chore(deploy):=20=E5=88=A0=E9=99=A4=20EC2=20?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=A0=88,=E8=BD=AC=E5=90=91=20RackNerd=20?= =?UTF-8?q?=E8=87=AA=E5=BB=BA=E8=8A=82=E7=82=B9=E6=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/ci.yml | 53 +--- .gitea/workflows/deploy.yml | 46 ---- ci/nginx-test.sh | 46 ---- deploy/.env.example | 8 - deploy/README.md | 81 ------ deploy/backup/Dockerfile | 54 ---- deploy/backup/backup.env.example | 75 ------ deploy/backup/crontab.txt | 20 -- deploy/backup/entrypoint.sh | 88 ------- deploy/backup/scripts/backup-full.sh | 152 ----------- deploy/backup/scripts/backup-inc.sh | 194 -------------- deploy/backup/scripts/dr-drill.sh | 306 ---------------------- deploy/backup/scripts/lib.sh | 110 -------- deploy/backup/scripts/restore.sh | 187 ------------- deploy/backup/scripts/verify-rpo-rto.sh | 167 ------------ deploy/bootstrap/README.md | 6 +- deploy/bootstrap/monitor/deadman-watch.sh | 7 +- deploy/certbot/cloudflare.ini.tmpl | 3 - deploy/docker-compose.yml | 99 ------- deploy/edge/Dockerfile | 10 - deploy/edge/conf.d/blog.conf | 37 --- deploy/edge/conf.d/jiu.conf | 87 ------ deploy/edge/conf.d/marzban.conf | 26 -- deploy/edge/conf.d/pangolin.conf | 4 - deploy/edge/conf.d/pay.conf | 16 -- deploy/edge/nginx.conf | 41 --- deploy/edge/pangolin-entrypoint.sh | 21 -- deploy/edge/stream.conf | 20 -- deploy/scripts/cutover.sh | 65 ----- deploy/scripts/deploy.sh | 47 ---- deploy/scripts/gen-secrets.sh | 95 ------- deploy/scripts/print-clients.sh | 31 --- deploy/scripts/rollback.sh | 34 --- deploy/singbox/config.tmpl.json | 24 -- deploy/xray/config.tmpl.json | 36 --- 35 files changed, 16 insertions(+), 2280 deletions(-) delete mode 100644 .gitea/workflows/deploy.yml delete mode 100644 ci/nginx-test.sh delete mode 100644 deploy/.env.example delete mode 100644 deploy/README.md delete mode 100644 deploy/backup/Dockerfile delete mode 100644 deploy/backup/backup.env.example delete mode 100644 deploy/backup/crontab.txt delete mode 100644 deploy/backup/entrypoint.sh delete mode 100644 deploy/backup/scripts/backup-full.sh delete mode 100644 deploy/backup/scripts/backup-inc.sh delete mode 100644 deploy/backup/scripts/dr-drill.sh delete mode 100644 deploy/backup/scripts/lib.sh delete mode 100644 deploy/backup/scripts/restore.sh delete mode 100644 deploy/backup/scripts/verify-rpo-rto.sh delete mode 100644 deploy/certbot/cloudflare.ini.tmpl delete mode 100644 deploy/docker-compose.yml delete mode 100644 deploy/edge/Dockerfile delete mode 100644 deploy/edge/conf.d/blog.conf delete mode 100644 deploy/edge/conf.d/jiu.conf delete mode 100644 deploy/edge/conf.d/marzban.conf delete mode 100644 deploy/edge/conf.d/pangolin.conf delete mode 100644 deploy/edge/conf.d/pay.conf delete mode 100644 deploy/edge/nginx.conf delete mode 100644 deploy/edge/pangolin-entrypoint.sh delete mode 100644 deploy/edge/stream.conf delete mode 100755 deploy/scripts/cutover.sh delete mode 100755 deploy/scripts/deploy.sh delete mode 100755 deploy/scripts/gen-secrets.sh delete mode 100755 deploy/scripts/print-clients.sh delete mode 100755 deploy/scripts/rollback.sh delete mode 100644 deploy/singbox/config.tmpl.json delete mode 100644 deploy/xray/config.tmpl.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3dfc060..c1afbf4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml deleted file mode 100644 index e9afe24..0000000 --- a/.gitea/workflows/deploy.yml +++ /dev/null @@ -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" diff --git a/ci/nginx-test.sh b/ci/nginx-test.sh deleted file mode 100644 index a579246..0000000 --- a/ci/nginx-test.sh +++ /dev/null @@ -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 配置校验通过。" diff --git a/deploy/.env.example b/deploy/.env.example deleted file mode 100644 index 2473095..0000000 --- a/deploy/.env.example +++ /dev/null @@ -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 diff --git a/deploy/README.md b/deploy/README.md deleted file mode 100644 index a2f76b4..0000000 --- a/deploy/README.md +++ /dev/null @@ -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_dump,age 加密,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' -``` diff --git a/deploy/backup/Dockerfile b/deploy/backup/Dockerfile deleted file mode 100644 index d70f115..0000000 --- a/deploy/backup/Dockerfile +++ /dev/null @@ -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"] diff --git a/deploy/backup/backup.env.example b/deploy/backup/backup.env.example deleted file mode 100644 index 3777e38..0000000 --- a/deploy/backup/backup.env.example +++ /dev/null @@ -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//_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; -# ────────────────────────────────────────────────────────── diff --git a/deploy/backup/crontab.txt b/deploy/backup/crontab.txt deleted file mode 100644 index 6106de4..0000000 --- a/deploy/backup/crontab.txt +++ /dev/null @@ -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 diff --git a/deploy/backup/entrypoint.sh b/deploy/backup/entrypoint.sh deleted file mode 100644 index b2dd9a0..0000000 --- a/deploy/backup/entrypoint.sh +++ /dev/null @@ -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}" diff --git a/deploy/backup/scripts/backup-full.sh b/deploy/backup/scripts/backup-full.sh deleted file mode 100644 index 385ce35..0000000 --- a/deploy/backup/scripts/backup-full.sh +++ /dev/null @@ -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 diff --git a/deploy/backup/scripts/backup-inc.sh b/deploy/backup/scripts/backup-inc.sh deleted file mode 100644 index 1e33f3f..0000000 --- a/deploy/backup/scripts/backup-inc.sh +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env bash -# backup-inc.sh —— 每 15 分钟增量备份(RPO ≤ 15min) -# MySQL:xtrabackup 增量 + binlog flush & 归档 -# PostgreSQL:pg_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_dump,15min 一次) -# ────────────────────────────────────────────────────── -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 diff --git a/deploy/backup/scripts/dr-drill.sh b/deploy/backup/scripts/dr-drill.sh deleted file mode 100644 index 148b68e..0000000 --- a/deploy/backup/scripts/dr-drill.sh +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env bash -# dr-drill.sh —— 月度灾备恢复演练 -# 目标:RPO ≤ 15min,RTO ≤ 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 - -# ────────────────────────────────────────────────────── -# 检查 4:PostgreSQL 恢复验证 -# ────────────────────────────────────────────────────── -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 diff --git a/deploy/backup/scripts/lib.sh b/deploy/backup/scripts/lib.sh deleted file mode 100644 index 019ea33..0000000 --- a/deploy/backup/scripts/lib.sh +++ /dev/null @@ -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 -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 -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 -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}" -} diff --git a/deploy/backup/scripts/restore.sh b/deploy/backup/scripts/restore.sh deleted file mode 100644 index 1266837..0000000 --- a/deploy/backup/scripts/restore.sh +++ /dev/null @@ -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. 停止 MySQL:systemctl 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. 启动 MySQL:systemctl 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 " - 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} [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 " - echo " restore.sh mysql inc " - echo " restore.sh postgres " - echo " restore.sh marzban " - exit 1 - ;; -esac diff --git a/deploy/backup/scripts/verify-rpo-rto.sh b/deploy/backup/scripts/verify-rpo-rto.sh deleted file mode 100644 index fa8419a..0000000 --- a/deploy/backup/scripts/verify-rpo-rto.sh +++ /dev/null @@ -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 diff --git a/deploy/bootstrap/README.md b/deploy/bootstrap/README.md index 6af3353..6c68917 100644 --- a/deploy/bootstrap/README.md +++ b/deploy/bootstrap/README.md @@ -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) diff --git a/deploy/bootstrap/monitor/deadman-watch.sh b/deploy/bootstrap/monitor/deadman-watch.sh index 2214ca3..8990d17 100755 --- a/deploy/bootstrap/monitor/deadman-watch.sh +++ b/deploy/bootstrap/monitor/deadman-watch.sh @@ -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 diff --git a/deploy/certbot/cloudflare.ini.tmpl b/deploy/certbot/cloudflare.ini.tmpl deleted file mode 100644 index e362ed5..0000000 --- a/deploy/certbot/cloudflare.ini.tmpl +++ /dev/null @@ -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__ diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml deleted file mode 100644 index 317d7cd..0000000 --- a/deploy/docker-compose.yml +++ /dev/null @@ -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//_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"] diff --git a/deploy/edge/Dockerfile b/deploy/edge/Dockerfile deleted file mode 100644 index 6e48a0f..0000000 --- a/deploy/edge/Dockerfile +++ /dev/null @@ -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"] diff --git a/deploy/edge/conf.d/blog.conf b/deploy/edge/conf.d/blog.conf deleted file mode 100644 index d930fd3..0000000 --- a/deploy/edge/conf.d/blog.conf +++ /dev/null @@ -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; -} diff --git a/deploy/edge/conf.d/jiu.conf b/deploy/edge/conf.d/jiu.conf deleted file mode 100644 index 5f5d245..0000000 --- a/deploy/edge/conf.d/jiu.conf +++ /dev/null @@ -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/:id(base-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; -} diff --git a/deploy/edge/conf.d/marzban.conf b/deploy/edge/conf.d/marzban.conf deleted file mode 100644 index 3c76de1..0000000 --- a/deploy/edge/conf.d/marzban.conf +++ /dev/null @@ -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; -} diff --git a/deploy/edge/conf.d/pangolin.conf b/deploy/edge/conf.d/pangolin.conf deleted file mode 100644 index e320991..0000000 --- a/deploy/edge/conf.d/pangolin.conf +++ /dev/null @@ -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 容器即可。 diff --git a/deploy/edge/conf.d/pay.conf b/deploy/edge/conf.d/pay.conf deleted file mode 100644 index f099406..0000000 --- a/deploy/edge/conf.d/pay.conf +++ /dev/null @@ -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; - } -} diff --git a/deploy/edge/nginx.conf b/deploy/edge/nginx.conf deleted file mode 100644 index 6aa1ccd..0000000 --- a/deploy/edge/nginx.conf +++ /dev/null @@ -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; diff --git a/deploy/edge/pangolin-entrypoint.sh b/deploy/edge/pangolin-entrypoint.sh deleted file mode 100644 index 20f7e67..0000000 --- a/deploy/edge/pangolin-entrypoint.sh +++ /dev/null @@ -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;' diff --git a/deploy/edge/stream.conf b/deploy/edge/stream.conf deleted file mode 100644 index 501ebc8..0000000 --- a/deploy/edge/stream.conf +++ /dev/null @@ -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; - } -} diff --git a/deploy/scripts/cutover.sh b/deploy/scripts/cutover.sh deleted file mode 100755 index 9a22593..0000000 --- a/deploy/scripts/cutover.sh +++ /dev/null @@ -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 diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh deleted file mode 100755 index f298d5d..0000000 --- a/deploy/scripts/deploy.sh +++ /dev/null @@ -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 "==> 完成 ✅" diff --git a/deploy/scripts/gen-secrets.sh b/deploy/scripts/gen-secrets.sh deleted file mode 100755 index a3106ec..0000000 --- a/deploy/scripts/gen-secrets.sh +++ /dev/null @@ -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 diff --git a/deploy/scripts/print-clients.sh b/deploy/scripts/print-clients.sh deleted file mode 100755 index a5a81e5..0000000 --- a/deploy/scripts/print-clients.sh +++ /dev/null @@ -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" <&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; } diff --git a/deploy/singbox/config.tmpl.json b/deploy/singbox/config.tmpl.json deleted file mode 100644 index c97b881..0000000 --- a/deploy/singbox/config.tmpl.json +++ /dev/null @@ -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" } - ] -} diff --git a/deploy/xray/config.tmpl.json b/deploy/xray/config.tmpl.json deleted file mode 100644 index 1942986..0000000 --- a/deploy/xray/config.tmpl.json +++ /dev/null @@ -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" } - ] -}