"""The off-site backup destination: an S3 bucket (Fabled Archives, or any S3 service) that the database dumps and the media blobs are sent to. The address, bucket, access key and region are plain settings. The secret key is sealed at rest with the credential key (`/images/secrets`), the same key that seals account logins, and no API ever returns it: the settings answer only whether one is set. Because the credential key is kept out of every backup, a database dump carries the secret only as ciphertext nobody can open without the original server. """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from urllib.parse import urlsplit from .backup_s3 import S3Error, S3Target, list_objects from .credential_crypto import CredentialCrypto, InvalidCredentialBlob KEY_PATH = Path("/images/secrets/credential_key.b64") DEFAULT_REGION = "us-east-1" _TEST_LISTING = 5 def _crypto() -> CredentialCrypto: return CredentialCrypto(KEY_PATH) def seal(secret: str) -> str: return _crypto().encrypt(secret).decode("ascii") def unseal(sealed: str) -> str: return _crypto().decrypt(sealed.encode("ascii")) @dataclass(frozen=True) class DestinationFields: endpoint: str | None bucket: str | None access_key: str | None region: str def clean_fields(body: dict) -> tuple[DestinationFields | None, str | None]: """The destination fields from a request body, trimmed, with empty ones as None; or (None, why) when the address can't be an S3 endpoint.""" def text(name: str) -> str | None: v = body.get(name) if v is None: return None if not isinstance(v, str): raise TypeError(name) return v.strip() or None try: endpoint = text("endpoint") bucket = text("bucket") access_key = text("access_key") region = text("region") or DEFAULT_REGION except TypeError as exc: return None, f"{exc.args[0]} must be text" if endpoint is not None: u = urlsplit(endpoint) if u.scheme not in ("http", "https") or not u.hostname: return None, ("the address must start with http:// or https:// and name " "a host, e.g. http://archives.example:8081") if u.query or u.fragment: return None, "the address can't carry a query or a fragment" endpoint = endpoint.rstrip("/") if bucket is not None and "/" in bucket: return None, "the bucket is a name, not a path: put any path in the address" return DestinationFields(endpoint, bucket, access_key, region), None def target_for(fields: DestinationFields, secret: str | None) -> S3Target | None: """The S3 target, or None while any part of it is missing.""" if not (fields.endpoint and fields.bucket and fields.access_key and secret): return None return S3Target(fields.endpoint, fields.bucket, fields.access_key, secret, region=fields.region) def saved_target(settings) -> S3Target | None: """The destination saved in ImportSettings, or None when none is set up. Raises S3Error when the secret can't be opened (the credential key changed since it was saved).""" sealed = settings.backup_s3_secret_sealed if not sealed: return None try: secret = unseal(sealed) except InvalidCredentialBlob: raise S3Error( "The saved secret key can't be opened on this server (the credential " "key changed since it was saved). Enter the secret key again." ) from None return target_for(fields_of(settings), secret) def fields_of(settings) -> DestinationFields: return DestinationFields( settings.backup_s3_endpoint, settings.backup_s3_bucket, settings.backup_s3_access_key, settings.backup_s3_region or DEFAULT_REGION, ) def describe(settings) -> dict: """The destination as the settings screen shows it: never the secret.""" f = fields_of(settings) return { "endpoint": f.endpoint or "", "bucket": f.bucket or "", "access_key": f.access_key or "", "region": f.region, "secret_set": bool(settings.backup_s3_secret_sealed), "upload_limit_mbps": settings.backup_upload_limit_mbps, "configured": bool( f.endpoint and f.bucket and f.access_key and settings.backup_s3_secret_sealed ), } def check_target(target: S3Target) -> dict: """Reach the bucket by listing it. A listing proves the address, the bucket and both keys at once, and writes nothing, because an Archives key is allowed to add objects but a test object could never be removed.""" try: page = list_objects(target, limit=_TEST_LISTING) except S3Error as exc: return {"ok": False, "message": str(exc)} if not page.objects: said = f"Reached {target.bucket} at {target.endpoint}. The bucket is empty." else: more = "+" if page.next_token else "" said = (f"Reached {target.bucket} at {target.endpoint}. It holds " f"{len(page.objects)}{more} object(s), e.g. {page.objects[0].key}.") return {"ok": True, "message": said}