v0.27.3
This commit is contained in:
1 parent
e84df445c6
commit
e87a382a7e
109 files changed
+1092
-2925
No files matched your search
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/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 <data-dir> backup create --output <backup.rpb>"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user