diff --git a/docs/migration-runbook.md b/docs/migration-runbook.md new file mode 100644 index 0000000..cd4c6c0 --- /dev/null +++ b/docs/migration-runbook.md @@ -0,0 +1,133 @@ +# 迁移 Runbook:vps-manager 换宿主平台 + +适用场景:当前宿主为 cc1(Tailscale `100.89.0.11`,HTTPS `https://dify.taile5765c.ts.net`); +选定长期平台后按本手册完成整体搬迁。目标:**数据零丢失、密文零重录、服务可回滚**。 + +--- + +## 1. 迁移面:什么要搬,什么能重建 + +| 类别 | 内容 | 迁移方式 | +|---|---|---| +| **唯一状态(必须搬)** | `data/assets.db`、`data/metrics.db`、`data/master_key.escrow`、`data/backups/`、`.env` | `make_migration_bundle.py` 打包 → `restore_migration_bundle.py` 恢复 | +| 可重建(不搬) | 代码 | 新机 `git clone` Gitea 仓库(`setup.sh` 自动) | +| 可重建(不搬) | Python venv 与依赖 | `setup.sh`(`pip install -r requirements.txt`) | +| 可重建(不搬) | systemd units 与全部 timer | `deploy/*.service\|timer` 在仓库内,`setup.sh` 安装 | +| 可重建(不搬) | Tailscale HTTPS serve | 一条 `tailscale serve --bg --https=443 http://127.0.0.1:8000` | +| **机器外状态** | 离线 `RESTORE_KEY` 抄本、Gitea deploy key、S3 凭据 | 手工:RESTORE_KEY 抄本随身;新机需可访问 Gitea(SSH key 或 HTTPS+token) | + +关键不变量:**`.env` 里的 `MASTER_KEY` 必须原样搬过去**——库内所有密文(平台账号 +api_config、凭据密码、2FA secret)都用它加密。MASTER_KEY 不变,密文即可直接读,无需重录。 + +--- + +## 2. 迁移前可选预处理(让将来迁移更省事) + +一次性做完,之后换平台时 agent 与访问地址都无需改动: + +1. **agent 地址去 IP 化**:把各被管 VPS 的 `/etc/vps-agent.env` 中 + `VPS_MANAGER_URL=http://100.89.0.11:8000` 改为 MagicDNS 名,例如 + `http://dify..ts.net:8000`;迁移时新设备沿用同名 `dify` 即可零改动。 + ```bash + sed -i 's#http://100.89.0.11:8000#http://<新地址>:8000#' /etc/vps-agent.env && systemctl restart vps-agent + ``` +2. **建立 MASTER_KEY 托管**(若尚未做):`python scripts/setup_key_escrow.py`, + RESTORE_KEY 抄到 2~3 个离线位置——迁移途中 `.env` 出意外时这是唯一后路。 +3. 确认备份 timer 正常:`systemctl list-timers | grep vps-`,且 `data/backups/escrow/` 有托管档。 + +--- + +## 3. 迁移步骤 + +### 3.1 源机(cc1)打包 +```bash +ssh cc1 +cd /opt/vps-manager +.venv/bin/python scripts/make_migration_bundle.py /tmp/vps-bundle.tar.gz +``` +记录输出的**包校验和**与文件数。打包用 sqlite backup API 在线快照,**无需停服**。 + +### 3.2 传输 +仅用 `scp` 或加密介质直传新机,不经第三方网盘/聊天工具: +```bash +scp /tmp/vps-bundle.tar.gz root@<新机>:/tmp/ +``` + +### 3.3 新机部署基座 +```bash +sudo tailscale up # 入网;建议设备名沿用 dify(保持 HTTPS URL 不变) +git clone /tmp/vps-manager-setup && cd /tmp/vps-manager-setup +sudo bash deploy/setup.sh # 依赖/venv/units/.env(临时)/服务启动 +``` +`setup.sh` 生成的临时 `.env` 会在 3.4 被包内 `.env` 覆盖(含真实 MASTER_KEY)。 + +### 3.4 停服并恢复状态 +```bash +cd /opt/vps-manager +sudo systemctl stop vps-manager vps-manager-update.timer +sudo .venv/bin/python scripts/restore_migration_bundle.py /tmp/vps-bundle.tar.gz +sudo systemctl start vps-manager +``` +脚本逐文件校验 sha256(不匹配即拒绝),并把新机原有 `data/`、`.env` 备份为 +`*.pre-restore-` 以便回滚。 + +### 3.5 访问入口 +```bash +sudo tailscale serve --bg --https=443 http://127.0.0.1:8000 +sudo tailscale status # 确认 https://<设备名>..ts.net +``` +若 URL 与旧的不同:把新域名加入 `/opt/vps-manager/.env` 的 `CORS_ORIGINS`, +`sudo systemctl restart vps-manager`,并更新浏览器书签 / 手机 PWA。 + +### 3.6 agent 与通知 +- 各被管 VPS:若未做 §2.1 预处理,逐台改 `VPS_MANAGER_URL` 指向新机并 `systemctl restart vps-agent`; +- 通知渠道(Telegram/SMTP)配置随 `.env` 迁移,无需改动,可用一次「立即同步」验证送达。 + +### 3.7 源机(cc1)下线 —— 防双写双通知 +确认新机运行正常**至少一个同步/备份周期**后: +```bash +ssh cc1 +sudo systemctl disable --now vps-manager vps-manager-update.timer \ + vps-backup-assets.timer vps-backup-metrics.timer vps-sync-ai.timer vps-renewal-check.timer +sudo rm -f /tmp/vps-bundle.tar.gz # 迁移包等同最高机密,用完即删 +``` +新机侧同样删除 `/tmp/vps-bundle.tar.gz`。 + +--- + +## 4. 验收清单(迁移后逐项打勾) + +- [ ] `curl https://<新地址>/health` 返回 `status=ok`,version/commit 与源机一致 +- [ ] 前端各视图数据完整:资产、订阅、域名、平台账号、监控、AI +- [ ] **凭据可解密**:`/api/credentials/{id}/password` 能解出明文(抽查 1~2 条) +- [ ] **2FA 动态码正确**:`/api/credentials/{id}/otp` 出的码能在目标站点通过验证 +- [ ] 平台账号 api_config 可解密:一次手动同步成功(验证 API 凭据未失效) +- [ ] `systemctl list-timers | grep vps-` 五个 timer 全部 enabled 且有下次触发时间 +- [ ] 监控数据开始进新机(`metrics.db` 有 agent 上报的新指标) +- [ ] 备份 timer 跑过一次:`data/backups/assets/`、`data/backups/escrow/` 有新文件(含 S3) +- [ ] 源机所有 timer 已 disable、服务已 stop(防双写) +- [ ] 源机与新机上的迁移包均已删除 + +--- + +## 5. 回滚 + +新机异常时(源机尚未下线): +```bash +sudo systemctl stop vps-manager +cd /opt/vps-manager && sudo mv data data.bad && sudo mv data.pre-restore- data +sudo mv .env .env.bad && sudo mv .env.pre-restore- .env +sudo systemctl start vps-manager +``` +源机已下线的极端情况:在源机重新 `systemctl enable --now` 各服务与 timer 即可 +(其数据未被改动,打包过程只读)。 + +--- + +## 6. 灾难恢复(与迁移不同的场景) + +- **仅 `.env`/MASTER_KEY 丢失**(库还在):`python scripts/recover_master_key.py --key --write` +- **整机丢失**(只有备份):新机 `setup.sh` → 从 S3/离线取最新 `data/backups/assets/assets_*.db` + 与 `metrics_*.db` 放回 `data/` → 取 `data/backups/escrow/master_key.escrow` 放回 `data/` → + 用离线 RESTORE_KEY 解出 MASTER_KEY 写入 `.env` → 起服验证解密。 +- **RESTORE_KEY 也丢**:无解,密文不可恢复(这正是 §2.2 要求多处离线抄本的原因)。 diff --git a/scripts/make_migration_bundle.py b/scripts/make_migration_bundle.py new file mode 100644 index 0000000..2caf0f5 --- /dev/null +++ b/scripts/make_migration_bundle.py @@ -0,0 +1,138 @@ +"""迁移打包:把 vps-manager 的唯一状态打成单一可校验迁移包 + +在源机器(如 cc1)运行: + .venv/bin/python scripts/make_migration_bundle.py [输出tar.gz路径] + +打包内容(唯一状态;代码/venv/systemd units/tailscale serve 均可由 +setup.sh 与一条 serve 命令重建,故不在包内): + - data/assets.db、data/metrics.db:sqlite backup API 在线一致性快照(无需停服) + - data/master_key.escrow:MASTER_KEY 托管档(若已建立) + - data/backups/:历史备份(含 escrow 副本) + - .env:MASTER_KEY / API_KEY / AGENT_KEY / 通知渠道 / S3 配置 + - manifest.json + manifest.sha256:版本、commit、主机、逐文件校验和 + +安全说明:包内含 MASTER_KEY 与全部密文,等同最高机密。传输仅用 scp 或加密 +介质,恢复完成并验证后即刻删除;切勿上传公开对象存储或聊天工具。 +恢复步骤见 scripts/restore_migration_bundle.py 与 docs/migration-runbook.md。 +""" + +import hashlib +import json +import shutil +import socket +import sqlite3 +import subprocess +import sys +import tarfile +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +BASE = Path(__file__).resolve().parent.parent + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def snap_db(src: Path, dst: Path) -> None: + """sqlite 在线一致性快照:backup API 保证事务边界完整,源库无需停服""" + con = sqlite3.connect(str(src)) + bkp = sqlite3.connect(str(dst)) + with bkp: + con.backup(bkp) + bkp.close() + con.close() + + +def read_git_commit() -> str: + try: + out = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], cwd=BASE, + capture_output=True, text=True, timeout=5, + ) + return out.stdout.strip() or "unknown" + except Exception: + return "unknown" + + +def main() -> int: + out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path( + f"/tmp/vps-manager-bundle-{datetime.now():%Y%m%d_%H%M%S}.tar.gz" + ) + env_src = BASE / ".env" + if not env_src.exists(): + print("[abort] 缺少 .env(MASTER_KEY 不在包内则迁移无意义)", file=sys.stderr) + return 2 + + work = Path(tempfile.mkdtemp(prefix="vps-bundle-")) + root = work / "bundle" + data_dst = root / "data" + data_dst.mkdir(parents=True) + data_src = BASE / "data" + + copied: list[str] = [] + for db in ("assets.db", "metrics.db"): + src = data_src / db + if src.exists(): + snap_db(src, data_dst / db) + copied.append(f"data/{db}") + else: + print(f"[skip] {db} 不存在") + escrow = data_src / "master_key.escrow" + if escrow.exists(): + shutil.copy2(escrow, data_dst / "master_key.escrow") + (data_dst / "master_key.escrow").chmod(0o600) + copied.append("data/master_key.escrow") + else: + print("[warn] 未建立 MASTER_KEY 托管(master_key.escrow 不存在),恢复链不完整") + backups_src = data_src / "backups" + if backups_src.is_dir(): + shutil.copytree(backups_src, data_dst / "backups") + for p in sorted((data_dst / "backups").rglob("*")): + if p.is_file(): + copied.append(str(p.relative_to(root))) + shutil.copy2(env_src, root / ".env") + (root / ".env").chmod(0o600) + copied.append(".env") + + manifest = { + "app": "vps-manager", + "version": (BASE / "VERSION").read_text(encoding="utf-8").strip() + if (BASE / "VERSION").exists() else "dev", + "commit": read_git_commit(), + "source_host": socket.gethostname(), + "created_at": datetime.now(timezone.utc).isoformat(), + "files": {name: {"sha256": sha256_file(root / name), "size": (root / name).stat().st_size} + for name in copied}, + } + (root / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + (root / "manifest.sha256").write_text( + "".join(f"{v['sha256']} {name}\n" for name, v in manifest["files"].items()), + encoding="utf-8", + ) + + out.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(out, "w:gz") as tar: + tar.add(root, arcname="bundle") + shutil.rmtree(work, ignore_errors=True) + + print(f"[ok] 迁移包:{out}") + print(f" 大小:{out.stat().st_size / 1024:.1f} KB") + print(f" 包校验和:{sha256_file(out)}") + print(f" 源主机:{manifest['source_host']} · 版本 {manifest['version']} ({manifest['commit']})") + print(f" 内含 {len(copied)} 个状态文件(两库快照 + escrow + backups + .env)") + print(" 恢复:新机器跑 setup.sh 后停服,执行") + print(f" .venv/bin/python scripts/restore_migration_bundle.py {out}") + print(" ⚠ 包等同最高机密:仅 scp/加密介质传输,验证后删除") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/restore_migration_bundle.py b/scripts/restore_migration_bundle.py new file mode 100644 index 0000000..d94dcad --- /dev/null +++ b/scripts/restore_migration_bundle.py @@ -0,0 +1,113 @@ +"""迁移恢复:校验并解包迁移包到目标部署目录 + +在新机器运行(前提:已跑 deploy/setup.sh 完成代码/venv/units 部署): + sudo systemctl stop vps-manager vps-manager-update.timer + .venv/bin/python scripts/restore_migration_bundle.py [--app-dir /opt/vps-manager] + sudo systemctl start vps-manager + +行为: + - 按 manifest.sha256 逐文件校验,任一不匹配即拒绝恢复(防传输损坏/调包) + - 目标机现有 data/ 与 .env 先备份为 *.pre-restore-(可回滚) + - 解包 data/(两库 + escrow + backups)与 .env(chmod 600) + - 打印后续手工步骤(serve/agent/CORS/源机 timer 下线,见 docs/migration-runbook.md) +""" + +import argparse +import hashlib +import shutil +import sys +import tarfile +import tempfile +from datetime import datetime +from pathlib import Path + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def main() -> int: + ap = argparse.ArgumentParser(description="校验并恢复 vps-manager 迁移包") + ap.add_argument("bundle", help="迁移包 tar.gz 路径") + ap.add_argument("--app-dir", default="/opt/vps-manager", help="目标部署目录") + args = ap.parse_args() + + bundle = Path(args.bundle) + app_dir = Path(args.app_dir) + if not bundle.exists(): + print(f"[abort] 迁移包不存在:{bundle}", file=sys.stderr) + return 2 + if not (app_dir / "app" / "main.py").exists(): + print("[abort] 目标目录不像已部署的 vps-manager(缺 app/main.py),请先跑 deploy/setup.sh", + file=sys.stderr) + return 2 + + work = Path(tempfile.mkdtemp(prefix="vps-restore-")) + try: + with tarfile.open(bundle) as tar: + try: + # Python >= 3.11.4/3.12:官方数据过滤器(防路径穿越/硬链接等) + tar.extractall(work, filter="data") + except TypeError: + # 新机自带旧版 Python(如 3.10)无 filter 参数:手工校验成员路径 + for m in tar.getmembers(): + if m.name.startswith("/") or ".." in Path(m.name).parts: + print(f"[abort] 包内路径异常,拒绝解包:{m.name}", file=sys.stderr) + return 2 + tar.extractall(work) + root = work / "bundle" + manifest_sha = root / "manifest.sha256" + if not manifest_sha.exists(): + print("[abort] 包内缺 manifest.sha256,拒绝恢复", file=sys.stderr) + return 2 + print("[1/4] 校验包内文件…") + for line in manifest_sha.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + digest, name = line.split(" ", 1) + target = root / name + if not target.exists(): + print(f"[abort] 包内缺文件:{name}", file=sys.stderr) + return 2 + actual = sha256_file(target) + if actual != digest: + print(f"[abort] 校验和不匹配:{name}\n 期望 {digest}\n 实际 {actual}", + file=sys.stderr) + return 2 + print(f" ok {name}") + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + print("[2/4] 备份目标机现有状态…") + for src, tag in ((app_dir / "data", f"data.pre-restore-{ts}"), + (app_dir / ".env", f".env.pre-restore-{ts}")): + if src.exists(): + dst = app_dir / tag + shutil.move(str(src), str(dst)) + print(f" {src} -> {dst}") + + print("[3/4] 恢复 data/ 与 .env…") + shutil.move(str(root / "data"), str(app_dir / "data")) + shutil.move(str(root / ".env"), str(app_dir / ".env")) + (app_dir / ".env").chmod(0o600) + escrow = app_dir / "data" / "master_key.escrow" + if escrow.exists(): + escrow.chmod(0o600) + + print("[4/4] 恢复完成。后续手工步骤(详见 docs/migration-runbook.md):") + print(" 1. systemctl start vps-manager && curl 127.0.0.1:8000/health 比对 version/commit") + print(" 2. tailscale serve --bg --https=443 http://127.0.0.1:8000(设备名沿用旧名可保持 URL 不变)") + print(" 3. .env 的 CORS_ORIGINS 加入新 HTTPS 域名(若 URL 变化)") + print(" 4. 各被管 VPS 的 /etc/vps-agent.env:VPS_MANAGER_URL 指向新地址后 restart vps-agent") + print(" 5. 确认新机数据无误后,源机 disable 全部 timer 并 stop 服务(防双写/双通知)") + print(" 6. 删除本迁移包与源机上的包副本(等同最高机密)") + return 0 + finally: + shutil.rmtree(work, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main())