This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Create a private, consistent SQLite backup, including a live database in WAL mode.
This does not copy source code or accept arbitrary SQL. Restore instructions are in docs/MIGRATION.md.
"""
from __future__ import annotations
import argparse
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:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data-dir", type=Path, default=Path("data"))
parser.add_argument("destination", type=Path, help="new directory; existing destinations are never overwritten")
args = parser.parse_args()
try:
print(f"Private SQLite and certificate backup created: {backup(args.data_dir, args.destination)}")
except (OSError, ValueError, sqlite3.Error, RuntimeError) as error:
parser.exit(1, f"Backup failed: {error}\nA partially-created destination must be inspected before reuse.\n")
if __name__ == "__main__":
main()