CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 27s
CI and images / backend-lint-and-test (push) Successful in 40s
CI and images / integration (push) Failing after 19s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
Settings → Maintenance → Storage gains "Off-site backup destination": the
address (with its port), bucket, region, access key and secret key of an S3
bucket such as Fabled Archives. The secret key is sealed with the credential
key, never returned by the API, and kept when the field is left blank. Test
destination lists the bucket with the fields as typed and writes nothing, and
names the cause when it fails ("nothing is listening there (check the port)",
"the secret key is wrong", "answered, but not as S3").
services/backup_s3 is a standard-library SigV4 client (put, get, paged list)
ported from Grimoire's; it is held to AWS's published signing examples and
exercised against a local HTTP server. Migration 0118 adds the columns; no
destination is set up until someone saves one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
5.0 KiB
Python
138 lines
5.0 KiB
Python
"""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),
|
|
"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}
|