FluxIP/deploy/backup.py

174 lines
6.1 KiB
Python
Raw Permalink 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 python3
"""Create a consistent SQLite backup, excluding the master key by default."""
from __future__ import annotations
import argparse
import base64
import hashlib
import os
import re
import shutil
import sqlite3
import tarfile
import tempfile
from contextlib import closing, suppress
from datetime import UTC, datetime
from pathlib import Path
BACKUP_ARTIFACT_PATTERN = re.compile(
r"fluxip-(?P<kind>database|complete)-backup-"
r"(?P<timestamp>\d{8}T\d{6}Z)\.tar\.gz(?:\.sha256)?"
)
def non_negative_int(value: str) -> int:
parsed = int(value)
if parsed < 0:
raise argparse.ArgumentTypeError("必须是非负整数")
return parsed
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="备份 FluxIP SQLite 数据库")
parser.add_argument("--database", type=Path, default=Path("data/fluxip.db"))
parser.add_argument("--output-dir", type=Path, default=Path("backups"))
parser.add_argument(
"--keep-last",
type=non_negative_int,
default=0,
metavar="N",
help="按备份类型只保留最近 N 套0 表示不自动清理",
)
parser.add_argument(
"--master-key",
type=Path,
help="要随数据库打包的主密钥;默认不打包",
)
parser.add_argument(
"--allow-plaintext-key-archive",
action="store_true",
help="明确允许把主密钥写入普通 tar.gz应立即转存到加密介质",
)
return parser.parse_args()
def validate_master_key(path: Path) -> None:
try:
encoded = path.read_bytes().strip()
decoded = base64.urlsafe_b64decode(encoded)
except (OSError, ValueError) as exc:
raise RuntimeError(f"主密钥文件无法读取或格式错误:{path}") from exc
if len(decoded) != 32:
raise RuntimeError("主密钥不是 32 字节 URL-safe Base64已停止备份")
def backup_database(source_path: Path, destination_path: Path) -> None:
with (
closing(sqlite3.connect(source_path, timeout=30)) as source,
closing(sqlite3.connect(destination_path)) as destination,
):
source.backup(destination)
result = destination.execute("PRAGMA quick_check").fetchone()
if not result or result[0] != "ok":
raise RuntimeError(f"SQLite 备份完整性检查失败:{result}")
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def create_private_file(path: Path) -> None:
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
os.close(descriptor)
def prune_backups(output_dir: Path, kind: str, keep_last: int) -> int:
if keep_last == 0:
return 0
artifacts: dict[str, list[Path]] = {}
for candidate in output_dir.iterdir():
if candidate.is_symlink() or not candidate.is_file():
continue
match = BACKUP_ARTIFACT_PATTERN.fullmatch(candidate.name)
if match is None or match.group("kind") != kind:
continue
artifacts.setdefault(match.group("timestamp"), []).append(candidate)
expired_timestamps = sorted(artifacts, reverse=True)[keep_last:]
for timestamp in expired_timestamps:
for artifact in artifacts[timestamp]:
artifact.unlink()
return len(expired_timestamps)
def main() -> int:
args = parse_args()
database = args.database.resolve()
master_key = args.master_key.resolve() if args.master_key else None
output_dir = args.output_dir.resolve()
if not database.is_file():
raise RuntimeError(f"数据库不存在:{database}")
if args.allow_plaintext_key_archive and master_key is None:
raise RuntimeError("使用 --allow-plaintext-key-archive 时必须同时提供 --master-key")
if master_key is not None and not args.allow_plaintext_key_archive:
raise RuntimeError(
"拒绝把主密钥写入未加密备份;如已准备加密存储,请显式增加 "
"--allow-plaintext-key-archive"
)
if master_key is not None:
if not master_key.is_file():
raise RuntimeError(f"主密钥文件不存在:{master_key}")
validate_master_key(master_key)
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
kind = "complete" if master_key is not None else "database"
archive = output_dir / f"fluxip-{kind}-backup-{timestamp}.tar.gz"
temporary_archive = archive.with_suffix(archive.suffix + ".tmp")
try:
create_private_file(temporary_archive)
with tempfile.TemporaryDirectory(prefix=".fluxip-", dir=output_dir) as temporary:
staging = Path(temporary)
backup_database(database, staging / "fluxip.db")
with tarfile.open(temporary_archive, "w:gz") as package:
package.add(staging / "fluxip.db", arcname="fluxip.db")
if master_key is not None:
shutil.copy2(master_key, staging / "master.key")
package.add(staging / "master.key", arcname="master.key")
os.replace(temporary_archive, archive)
finally:
with suppress(FileNotFoundError):
temporary_archive.unlink()
with suppress(OSError):
archive.chmod(0o600)
checksum = archive.with_name(archive.name + ".sha256")
checksum.write_text(f"{sha256(archive)} {archive.name}\n", encoding="ascii")
with suppress(OSError):
checksum.chmod(0o600)
removed_sets = prune_backups(output_dir, kind, args.keep_last)
print(f"备份完成:{archive}")
print(f"校验文件:{checksum}")
if args.keep_last:
print(f"保留策略:{kind} 类型最近 {args.keep_last} 套,已清理 {removed_sets}")
if master_key is None:
print("此备份不含主密钥;请确认主密钥已在独立的加密位置托管")
else:
print("警告:备份包含主密钥,请立即转存到加密介质并删除非必要副本")
return 0
if __name__ == "__main__":
raise SystemExit(main())