#!/usr/bin/env python3 """Legacy SQLite snapshot helper retained for offline release tests. Operator backups must use `rust-proxy backup create`, which includes the encrypted Secret Store key material and all durable data. This script is not a supported backup/restore interface after the Secret Store migration. """ from __future__ import annotations import hashlib import json import os import re from pathlib import Path import sqlite3 import stat import time from urllib.parse import quote def backup(data_dir: Path, destination: Path) -> Path: os.umask(0o077) if data_dir.is_symlink() or not data_dir.is_dir(): raise ValueError("Data directory must be a real directory, not a symlink.") if os.name == "posix" and stat.S_IMODE(data_dir.stat().st_mode) & 0o077: raise ValueError("Data directory must have mode 700.") source = data_dir / "config.sqlite3" if source.is_symlink() or not source.is_file(): raise ValueError("config.sqlite3 must be a regular, existing file.") destination.mkdir(mode=0o700, parents=False, exist_ok=False) target = destination / "config.sqlite3" # The online backup API incorporates committed WAL contents; a plain file copy does not. reader = sqlite3.connect(f"file:{quote(str(source.resolve()), safe='/')}?mode=ro", uri=True, timeout=10) writer = sqlite3.connect(target) try: reader.backup(writer, pages=256, sleep=0.05) check = writer.execute("PRAGMA quick_check").fetchone()[0] if check != "ok": raise RuntimeError(f"SQLite integrity check failed: {check}") schema = writer.execute("PRAGMA user_version").fetchone()[0] revision, document = writer.execute("SELECT revision, document FROM configuration WHERE id = 1").fetchone() config = json.loads(document) finally: writer.close() reader.close() os.chmod(target, 0o600) digest = hashlib.sha256() with target.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) checksum = digest.hexdigest() certificate_hashes = {} names = set() settings = config.get("tls", {}) for binding in settings.get("certificates", []): names.update([binding["cert_file"], binding["key_file"]]) cert_dir = data_dir / "certs" if cert_dir.exists(): if cert_dir.is_symlink() or not cert_dir.is_dir(): raise ValueError("Certificate directory must not be a symlink.") for name in ["default.pem", "default.key"]: if (cert_dir / name).exists(): names.add(name) if len(names) > 130: raise ValueError("Too many configured certificate files.") if names: (destination / "certs").mkdir(mode=0o700) for name in sorted(names): if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,96}", name) or ".." in name: raise ValueError("Unsafe certificate filename in the saved configuration.") flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) with os.fdopen(os.open(cert_dir / name, flags), "rb") as stream: info = os.fstat(stream.fileno()) if not stat.S_ISREG(info.st_mode) or info.st_size > 131072: raise ValueError("Certificate input must be a regular file up to 128 KiB.") content = stream.read(131073) if len(content) > 131072: raise ValueError("Certificate input grew beyond 128 KiB.") with (destination / "certs" / name).open("xb") as stream: stream.write(content) os.chmod(destination / "certs" / name, 0o600) certificate_hashes[name] = hashlib.sha256(content).hexdigest() metadata = {"project":"RustProxy", "created_at_unix":int(time.time()), "schema":schema, "configuration_revision":revision, "sha256":checksum, "certificate_sha256":certificate_hashes, "warning":"Contains password hashes, session state and configured TLS private keys. Keep private. Revoke sessions after restoring."} (destination / "backup.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8") return target def main() -> None: raise SystemExit( "tools/legacy/backup.py is retired for operator backups. " "Use: rust-proxy --data-dir backup create --output " ) if __name__ == "__main__": main()