prole/etc/archive_garage_backup.py

195 lines
6.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Download a backup set from Garage S3 to a local archive directory."""
import argparse
import datetime
import hashlib
import hmac
import os
import sys
import urllib.request
import xml.etree.ElementTree as ET
from urllib.parse import urlparse, quote
def _aws_quote(value: str) -> str:
return quote(value, safe="-_.~")
def _canonical_query(params: dict) -> str:
if not params:
return ""
items = []
for key in sorted(params.keys()):
val = params[key]
items.append(f"{_aws_quote(str(key))}={_aws_quote(str(val))}")
return "&".join(items)
def _sign(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def _signature_key(secret_key: str, date_stamp: str, region: str, service: str) -> bytes:
k_date = _sign(("AWS4" + secret_key).encode("utf-8"), date_stamp)
k_region = _sign(k_date, region)
k_service = _sign(k_region, service)
k_signing = _sign(k_service, "aws4_request")
return k_signing
def _canonical_uri(path: str) -> str:
if not path.startswith("/"):
path = "/" + path
parts = [_aws_quote(p) for p in path.split("/")]
return "/".join(parts)
def _sign_request(
method: str, host: str, path: str, params: dict, access_key: str, secret_key: str, region: str, service: str
) -> tuple[dict[str, str], str, str]:
"""Sign an AWS request and return headers, canonical URI, and canonical query string."""
now = datetime.datetime.now(datetime.timezone.utc)
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
payload_hash = hashlib.sha256(b"").hexdigest()
canonical_uri = _canonical_uri(path)
canonical_querystring = _canonical_query(params)
canonical_headers = (
f"host:{host}\n"
f"x-amz-content-sha256:{payload_hash}\n"
f"x-amz-date:{amz_date}\n"
)
signed_headers = "host;x-amz-content-sha256;x-amz-date"
canonical_request = "\n".join([
method,
canonical_uri,
canonical_querystring,
canonical_headers,
signed_headers,
payload_hash,
])
algorithm = "AWS4-HMAC-SHA256"
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
hashed_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
string_to_sign = "\n".join([algorithm, amz_date, credential_scope, hashed_request])
signing_key = _signature_key(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization_header = (
f"{algorithm} Credential={access_key}/{credential_scope}, "
f"SignedHeaders={signed_headers}, Signature={signature}"
)
headers = {
"x-amz-date": amz_date,
"x-amz-content-sha256": payload_hash,
"Authorization": authorization_header,
}
return headers, canonical_uri, canonical_querystring
def _aws_request(method: str, endpoint: str, path: str, params: dict, access_key: str, secret_key: str, region: str):
parsed = urlparse(endpoint)
host = parsed.netloc
headers, canonical_uri, canonical_querystring = _sign_request(
method, host, path, params, access_key, secret_key, region, "s3"
)
url = f"{parsed.scheme}://{host}{canonical_uri}"
if canonical_querystring:
url = f"{url}?{canonical_querystring}"
req = urllib.request.Request(url, method=method, headers=headers)
return urllib.request.urlopen(req)
def _list_objects(endpoint: str, bucket: str, prefix: str, access_key: str, secret_key: str, region: str):
token = ""
while True:
params = {"list-type": "2"}
if prefix:
params["prefix"] = prefix
if token:
params["continuation-token"] = token
with _aws_request("GET", endpoint, f"/{bucket}", params, access_key, secret_key, region) as resp:
data = resp.read()
root = ET.fromstring(data)
for entry in root.findall(".//{*}Contents"):
key = entry.findtext("{*}Key")
if key:
yield key
is_truncated = root.findtext(".//{*}IsTruncated")
if str(is_truncated).lower() != "true":
break
token = root.findtext(".//{*}NextContinuationToken") or ""
if not token:
break
def _safe_join(base: str, rel: str) -> str:
rel = rel.lstrip("/")
path = os.path.normpath(os.path.join(base, rel))
base_norm = os.path.normpath(base)
if not path.startswith(base_norm):
raise ValueError("Refusing to write outside archive directory")
return path
def main() -> int:
parser = argparse.ArgumentParser(description="Archive Garage S3 backups locally")
parser.add_argument("--endpoint", required=True)
parser.add_argument("--region", required=True)
parser.add_argument("--access-key", required=True)
parser.add_argument("--secret-key", required=True)
parser.add_argument("--bucket", required=True)
parser.add_argument("--prefix", default="")
parser.add_argument("--contains", default="")
parser.add_argument("--dest", required=True)
args = parser.parse_args()
os.makedirs(args.dest, exist_ok=True)
prefix = args.prefix
if prefix and not prefix.endswith("/"):
prefix = prefix + "/"
keys = []
for key in _list_objects(args.endpoint, args.bucket, prefix, args.access_key, args.secret_key, args.region):
if args.contains and args.contains not in key:
continue
keys.append(key)
if not keys:
print("No matching objects found in bucket.", file=sys.stderr)
return 2
for key in keys:
rel = key
if prefix and key.startswith(prefix):
rel = key[len(prefix):]
if not rel:
rel = os.path.basename(key)
dest_path = _safe_join(args.dest, rel)
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
with _aws_request("GET", args.endpoint, f"/{args.bucket}/{key}", {}, args.access_key, args.secret_key, args.region) as resp:
with open(dest_path, "wb") as fh:
while True:
chunk = resp.read(1024 * 1024)
if not chunk:
break
fh.write(chunk)
print(f"Archived {key} -> {dest_path}")
return 0
if __name__ == "__main__":
sys.exit(main())