265 lines
11 KiB
Python
265 lines
11 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 time
|
|
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 wait_nextcloud(directory, timeout):
|
|
timeout = int(timeout)
|
|
if timeout < 1 or timeout > 3600:
|
|
raise ValueError("WRITER_DRAIN_TIMEOUT must be 1..3600 seconds")
|
|
expected = {os.path.realpath(directory + "/cron.php"), os.path.realpath(directory + "/occ")}
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
found = []
|
|
for process in Path("/proc").iterdir():
|
|
if not process.name.isdigit() or int(process.name) == os.getpid():
|
|
continue
|
|
try:
|
|
args = (process / "cmdline").read_bytes().split(b"\0")
|
|
cwd = os.readlink(process / "cwd")
|
|
# Exact script arguments only: do not match this helper's NC_DIR
|
|
# argument, unrelated files, or arbitrary shell command strings.
|
|
if any(arg and os.path.realpath(os.path.join(cwd, os.fsdecode(arg))) in expected for arg in args):
|
|
found.append(process.name)
|
|
except (FileNotFoundError, ProcessLookupError):
|
|
continue
|
|
if not found:
|
|
return
|
|
if time.monotonic() >= deadline:
|
|
raise ValueError("Nextcloud writers still active (PIDs: " + ", ".join(found) + ")")
|
|
time.sleep(1)
|
|
|
|
|
|
def main():
|
|
op, *args = sys.argv[1:]
|
|
if op == "wait-nextcloud":
|
|
wait_nextcloud(*args)
|
|
elif 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)
|