Files
vps-manager/deploy/backup.sh
T
gouki 6865bb7914 fix(backup): 备份链路纳入 master_key.escrow(覆盖式单份+S3)
escrow 是 MASTER_KEY 遗失恢复链的一环:整机丢失时仅有库备份+离线 RESTORE_KEY 仍无法恢复,缺的正是托管档。随 assets 周期备份,固定文件名覆盖(多版本无意义),未建立托管时 skip。
2026-09-05 23:10:46 +00:00

226 lines
7.9 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 数据库分库备份脚本(本地 + S3 兼容对象存储)
# 用法:backup.sh [assets|metrics|all]
# assets -> 备份 assets.db + master_key.escrow,保留 14 份(建议每日)
# metrics -> 备份 metrics.db,保留 4 份(建议每周)
# all -> 上述都备份
#
# master_key.escrow 随 assets 周期备份(覆盖式单份):它是被离线 RESTORE_KEY
# 加密的 MASTER_KEY 托管档,进备份/S3 安全;缺了它,整机丢失时即使有库备份
# 和离线钥匙也无法恢复。
#
# S3 上传(可选):配置 .env 中 S3_* 项后,备份文件自动上传到
# 任意 S3 兼容对象存储(Cloudflare R2 / MinIO / 阿里云 OSS / 腾讯云 COS 等),
# 远端保留份数与本地一致。未配置 S3 时仅本地备份(向后兼容)。
# 实现为纯 Python 标准库(AWS SigV4 签名),零额外依赖。
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/vps-manager}"
cd "$APP_DIR"
TARGET="${1:-all}"
python3 - "$TARGET" <<'PYEOF'
import hashlib
import hmac
import os
import sqlite3
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
target = sys.argv[1]
data_dir = Path("data")
backup_root = data_dir / "backups"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
# ---------------- 本地备份 ----------------
def backup(db_name: str, keep: int) -> Path | None:
src = data_dir / db_name
stem = db_name.replace(".db", "")
if not src.exists():
print(f"[skip] {db_name} 不存在")
return None
out_dir = backup_root / stem
out_dir.mkdir(parents=True, exist_ok=True)
dst = out_dir / f"{stem}_{ts}.db"
con = sqlite3.connect(str(src))
bkp = sqlite3.connect(str(dst))
con.backup(bkp)
bkp.close()
con.close()
files = sorted(
out_dir.glob(f"{stem}_*.db"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
for old in files[keep:]:
old.unlink()
print(f"[ok] {db_name} -> {dst}(保留 {keep} 份)")
return dst
# ---------------- S3 上传(纯标准库 SigV4 ----------------
def load_env(path: Path) -> dict:
"""极简 .env 解析(KEY=VALUE,忽略注释/空行),os.environ 优先"""
env = {}
if path.exists():
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
for k, v in os.environ.items():
if v:
env[k] = v
return env
def _sign(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
def _sig_key(secret: str, date: str, region: str, service: str) -> bytes:
k = _sign(f"AWS4{secret}".encode(), date)
k = _sign(k, region)
k = _sign(k, service)
return _sign(k, "aws4_request")
def _s3_request(endpoint: str, access: str, secret: str, region: str,
method: str, bucket: str, key: str, query: str,
body: bytes, content_type: str = "application/octet-stream") -> bytes:
"""S3 API 请求(path-style),返回响应体"""
now = datetime.now(timezone.utc)
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date = now.strftime("%Y%m%d")
payload_hash = hashlib.sha256(body).hexdigest()
host = urllib.parse.urlparse(endpoint).netloc
q = urllib.parse.quote(key, safe="/")
canonical_uri = f"/{bucket}/{q}"
canonical_query = query # 已按 RFC3986 编码
canonical_headers = (
f"host:{host}\n"
f"x-amz-content-sha256:{payload_hash}\n"
f"x-amz-date:{amz_date}\n"
)
signed_headers = "host;x-amz-content-sha256;x-amz-date"
canonical_request = "\n".join(
[method, canonical_uri, canonical_query, canonical_headers, signed_headers, payload_hash]
)
scope = f"{date}/{region}/s3/aws4_request"
string_to_sign = "\n".join(
["AWS4-HMAC-SHA256", amz_date, scope,
hashlib.sha256(canonical_request.encode()).hexdigest()]
)
signature = hmac.new(
_sig_key(secret, date, region, "s3"), string_to_sign.encode(), hashlib.sha256
).hexdigest()
auth = (
f"AWS4-HMAC-SHA256 Credential={access}/{scope}, "
f"SignedHeaders={signed_headers}, Signature={signature}"
)
url = f"{endpoint.rstrip('/')}/{bucket}/{q}"
if query:
url += "?" + query
req = urllib.request.Request(
url, data=body, method=method,
headers={
"Authorization": auth,
"x-amz-content-sha256": payload_hash,
"x-amz-date": amz_date,
"Content-Type": content_type,
},
)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
return resp.read()
except urllib.error.HTTPError as e:
raise RuntimeError(f"S3 {method} {key} 失败: HTTP {e.code} {e.read().decode(errors='replace')[:300]}")
def s3_upload(env: dict, dst: Path, stem: str, keep: int) -> None:
"""上传单个备份文件到 S3,并清理远端过期备份(保留 keep 份)"""
endpoint = env.get("S3_ENDPOINT_URL", "")
access = env.get("S3_ACCESS_KEY", "")
secret = env.get("S3_SECRET_KEY", "")
bucket = env.get("S3_BUCKET", "")
if not (endpoint and access and secret and bucket):
print("[s3] 未配置 S3_*ENDPOINT/ACCESS_KEY/SECRET_KEY/BUCKET),跳过远端备份")
return
region = env.get("S3_REGION", "auto")
prefix = env.get("S3_PREFIX", "vps-manager").rstrip("/")
key = f"{prefix}/{stem}/{dst.name}"
body = dst.read_bytes()
_s3_request(endpoint, access, secret, region, "PUT", bucket, key, "", body)
print(f"[s3] {dst.name} -> {endpoint}/{bucket}/{key}")
# 列出同目录下已有备份,按 key 排序(时间戳命名即字典序),清理过期对象
# 注意:prefix 必须带参数名且值按 RFC3986 编码(含 / -> %2F),否则会列出全桶并误删其他目录
list_query = "list-type=2&prefix=" + urllib.parse.quote(f"{prefix}/{stem}/", safe="")
resp = _s3_request(endpoint, access, secret, region, "GET", bucket, "", list_query, b"")
root = ET.fromstring(resp)
ns = {"s3": "http://s3.amazonaws.com/doc/2006-03-01/"}
keys = [e.text for e in root.findall(".//s3:Key", ns) if e.text]
keys.sort(reverse=True)
for old_key in keys[keep:]:
_s3_request(endpoint, access, secret, region, "DELETE", bucket, old_key, "", b"")
print(f"[s3] 清理过期备份: {old_key}")
def run(db_name: str, keep: int) -> None:
dst = backup(db_name, keep)
if dst is None:
return
try:
env = load_env(Path(".env"))
s3_upload(env, dst, db_name.replace(".db", ""), keep)
except Exception as e:
# 本地备份已成功;S3 失败需要显式暴露(systemd 会标记 failed
print(f"[s3][error] {e}", file=sys.stderr)
sys.exit(1)
def backup_escrow() -> None:
"""备份 MASTER_KEY 托管档(覆盖式单份 + S3)
escrow 多版本无意义(解出的都是当前 MASTER_KEY),故固定文件名覆盖;
重建托管(--force)后下次备份自动带上新档。
"""
import shutil
src = data_dir / "master_key.escrow"
if not src.exists():
print("[skip] master_key.escrow 不存在(尚未建立托管)")
return
out_dir = backup_root / "escrow"
out_dir.mkdir(parents=True, exist_ok=True)
dst = out_dir / "master_key.escrow"
shutil.copy2(src, dst)
dst.chmod(0o600)
print(f"[ok] master_key.escrow -> {dst}(覆盖式单份)")
try:
env = load_env(Path(".env"))
s3_upload(env, dst, "escrow", 14)
except Exception as e:
print(f"[s3][error] escrow 上传失败: {e}", file=sys.stderr)
sys.exit(1)
if target in ("assets", "all"):
run("assets.db", 14)
backup_escrow()
if target in ("metrics", "all"):
run("metrics.db", 4)
PYEOF