100 lines
4.6 KiB
Python
Executable File
100 lines
4.6 KiB
Python
Executable File
#!/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()
|