Files
app-backup/lib/backup_set.py
T

235 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Strict backup-set metadata and tar validation; never execute backup metadata.
GNU tar performs extraction to preserve ACLs/xattrs. This helper first validates
all member names and link relationships, independent of archive member order.
"""
import configparser
import datetime as dt
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import subprocess
import sys
import tarfile
FORMAT = 2
LABEL = re.compile(r"(?:meta|wordpress|nextcloud(?:-data)?|gitea(?:-etc|-repos|-custom)?|mail|postfix|dovecot|mail-home-\d+|extra-\d+|db-(?:wordpress|nextcloud|gitea))\Z")
RUN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})_[0-9a-f]{8}\Z")
def digest(path):
h = hashlib.sha256()
with open(path, "rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def regular(path):
if path.is_symlink() or not path.is_file():
raise ValueError(f"Not a regular file: {path}")
def expected_root(label):
if label == "meta":
return "meta"
if label.startswith("db-"):
return f"db/{label[3:]}.sql"
return f"files/{label}"
def load_set(folder, metadata_only=False):
regular(folder / "manifest.json")
regular(folder / "COMPLETE")
raw = (folder / "manifest.json").read_bytes()
if len(raw) > 1024 * 1024:
raise ValueError("Manifest too large")
if (folder / "COMPLETE").read_text() != f"APP_BACKUP_V2 {hashlib.sha256(raw).hexdigest()}\n":
raise ValueError("Missing or mismatched completion marker")
data = json.loads(raw)
if data["format"] != FORMAT or not RUN.fullmatch(data["run_id"]):
raise ValueError("Unsupported backup format/run ID")
if not isinstance(data["created_epoch"], int) or data["created_epoch"] <= 0:
raise ValueError("Invalid creation time")
seen = set()
if not isinstance(data["archives"], list) or not data["archives"]:
raise ValueError("Empty backup set")
for item in data["archives"]:
label = item["label"]
if not LABEL.fullmatch(label) or label in seen:
raise ValueError(f"Invalid/duplicate component: {label}")
seen.add(label)
if item["root"] != expected_root(label):
raise ValueError("Unexpected archive root")
if item["file"] not in (f"{label}.tar.gz", f"{label}.tar.zst"):
raise ValueError("Unsafe archive filename")
if not re.fullmatch(r"[0-9a-f]{64}", item["sha256"]):
raise ValueError("Invalid SHA256")
if not isinstance(item["size"], int) or item["size"] <= 0:
raise ValueError("Invalid archive size")
if not metadata_only:
archive = folder / item["file"]
regular(archive)
if archive.stat().st_size != item["size"] or digest(archive) != item["sha256"]:
raise ValueError(f"Archive checksum/size mismatch: {archive.name}")
if "meta" not in seen:
raise ValueError("Missing metadata component")
if not metadata_only:
allowed = {a["file"] for a in data["archives"]} | {"manifest.json", "COMPLETE", "UPLOADED"}
if {p.name for p in folder.iterdir()} - allowed:
raise ValueError("Unexpected files in backup set")
return data
def member_name(value):
path = PurePosixPath(value)
if path.is_absolute() or ".." in path.parts or not path.parts:
raise ValueError(f"Unsafe tar path: {value!r}")
if any(ord(c) < 32 for c in value):
raise ValueError("Control character in tar path")
return str(path)
def check_tar(path, roots):
command = ["zstd", "-dc", "--", str(path)] if str(path).endswith(".zst") else ["gzip", "-dc", "--", str(path)]
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
entries = {}
hardlinks = []
try:
with tarfile.open(fileobj=proc.stdout, mode="r|") as archive:
for item in archive:
name = member_name(item.name)
if not any(name == root or name.startswith(root + "/") for root in roots):
raise ValueError(f"Member outside component root: {name}")
if name in entries:
raise ValueError(f"Duplicate tar member: {name}")
if not (item.isdir() or item.isfile() or item.issym() or item.islnk()):
raise ValueError(f"Special device/FIFO/socket not supported: {name}")
entries[name] = "dir" if item.isdir() else "file" if item.isfile() else "link"
if item.islnk():
hardlinks.append((name, member_name(item.linkname)))
# Symlinks may point outside the archive (e.g. /etc/alternatives),
# but no member may be extracted THROUGH a symlink or hardlink.
for name in entries:
for parent in PurePosixPath(name).parents:
if str(parent) in entries and entries[str(parent)] != "dir":
raise ValueError(f"Tar member descends through a non-directory: {name}")
for name, target in hardlinks:
if entries.get(target) != "file":
raise ValueError(f"Hardlink target is not an archived regular file: {name}")
if any(entries.get(root) != ("file" if root.endswith(".sql") else "dir") for root in roots):
raise ValueError("Required archive root missing")
# Consume the compression trailer, including errors after the tar EOF.
while proc.stdout.read(1024 * 1024):
pass
if proc.wait() != 0:
raise ValueError(f"Decompression failed: {path}")
finally:
proc.stdout.close()
if proc.poll() is None:
proc.terminate()
proc.wait()
def create(folder, run_id, plan):
entries = []
for line in Path(plan).read_text().splitlines():
label, source = line.split("\t")
matches = list(folder.glob(f"{label}.tar.*"))
if len(matches) != 1:
raise ValueError(f"Missing/ambiguous archive: {label}")
path = matches[0]
check_tar(path, [expected_root(label)])
entries.append(dict(label=label, source=source, root=expected_root(label), file=path.name,
size=path.stat().st_size, sha256=digest(path)))
manifest = dict(format=FORMAT, run_id=run_id, created_epoch=int(dt.datetime.now(dt.timezone.utc).timestamp()),
hostname=os.uname().nodename, archives=entries)
(folder / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=True) + "\n")
(folder / "COMPLETE").write_text(f"APP_BACKUP_V2 {digest(folder / 'manifest.json')}\n")
def crypt(name):
cfg = configparser.ConfigParser(interpolation=None)
try:
cfg.read_string(sys.stdin.read())
except configparser.Error:
raise ValueError("Invalid rclone configuration (details suppressed)") from None
remote = cfg[name]
if remote.get("type") != "crypt":
raise ValueError("Remote must have type=crypt")
if remote.get("filename_encryption", "standard") != "standard":
raise ValueError("Require filename_encryption=standard")
if not remote.getboolean("directory_name_encryption", True):
raise ValueError("Directory encryption is disabled")
if remote.getboolean("no_data_encryption", False):
raise ValueError("Content encryption is disabled")
print("Crypt configuration accepted (contents, filenames, directories)")
def candidates(prefix, days, keep):
# Only v2 names supplied AFTER completion-marker validation are eligible.
names = set(sys.stdin.read().splitlines())
valid = []
for name in names:
match = RUN.fullmatch(name)
if match and name.startswith(prefix + "_"):
date = dt.datetime.strptime(match[1], "%Y-%m-%d_%H-%M-%S").replace(tzinfo=dt.timezone.utc)
valid.append((date, name))
valid.sort(reverse=True)
cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=int(days))
for date, name in valid[int(keep):]:
if date < cutoff:
print(name)
def write_status(folder, operation, run_id, code):
data = dict(operation=operation, run=run_id, exit_code=int(code),
time=int(dt.datetime.now(dt.timezone.utc).timestamp()))
names = [f"last-{operation}.json"]
if operation == "backup" and int(code) == 0:
names.append("last-successful-backup.json")
for name in names:
path = Path(folder) / name
with open(str(path) + ".tmp", "w") as stream:
json.dump(data, stream)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(str(path) + ".tmp", path)
def main():
op, *args = sys.argv[1:]
if op == "status":
write_status(*args)
elif op == "crypt":
crypt(*args)
elif op == "create":
create(Path(args[0]), *args[1:])
elif op in ("verify", "metadata", "list"):
data = load_set(Path(args[0]), metadata_only=op == "metadata")
if op == "verify":
for item in data["archives"]:
check_tar(Path(args[0]) / item["file"], [item["root"]])
print(f"Verified: {data['run_id']} ({len(data['archives'])} archives)")
elif op == "list":
for item in data["archives"]:
print("\t".join([item["label"], item["file"], item["root"]]))
elif op == "candidates":
candidates(*args)
elif op == "tar":
check_tar(Path(args[0]), args[1:])
else:
raise ValueError("Unknown operation")
if __name__ == "__main__":
try:
main()
except (ValueError, KeyError, OSError, tarfile.TarError, configparser.Error) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
sys.exit(1)