mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
funkwhale has two S3 surfaces sharing one private bucket. Audio goes out as X-Accel-Redirect and the front nginx fetches it server-side, but attachments (instance logo, avatars, covers) land in the API JSON as absolute URLs the BROWSER dials. AWS_S3_ENDPOINT_URL drives the boto3 client, so with the internal endpoint django-storages signed URLs for http://seaweedfs:8333, a host no browser can reach. Point the client at the new bucket-less public_endpoint key. Measured against the live gateway: a URL signed for seaweedfs:8333 and sent to the public host answers 403 SignatureDoesNotMatch, while one signed for the public host reaches bucket lookup — query-string SigV4 binds the Host, so the signing host and the fetch host must be the same string. AWS_S3_CUSTOM_DOMAIN cannot serve this: storages/backends/s3.py:689 returns UNSIGNED without a CloudFront signer, and the bucket also holds the music library that music/views.py exists to gate. AWS_CA_BUNDLE follows, because the public endpoint is TLS on the local CA and botocore reads its trust store only from that variable (configprovider.py:59) — sys-svc-compose-ca injects SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE and NODE_EXTRA_CA_CERTS, never this one. It takes the bundle rather than the bare root cert, since AWS_CA_BUNDLE replaces the default store. AWS_QUERYSTRING_AUTH=True fixes a pre-existing break: upstream defaults it to `not PROXY_MEDIA`, which produced unsigned URLs against a private bucket. The new front nginx.conf.j2 serves /_protected/media, which the stock template resolves with `alias ${MEDIA_ROOT}/$1;` and therefore 404s for every S3-backed listen. web-svc-seaweedfs keeps no onion vhost. Its tor-contract reason now states the mechanical cause instead of the old claim that the role is never a Tor-browser surface, which decidim and funkwhale have made false: the node hidden service publishes http only while sys-front-tls keys listen/ssl on application_id, so an onion vhost would listen on 443 against a port-80 hidden service. Attachment fetches therefore still fail on an onion pick, and the spec is left to fail there rather than record the miss and pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
278 lines
8.7 KiB
Python
278 lines
8.7 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
from ansible.errors import AnsibleError
|
|
from ansible.plugins.loader import lookup_loader
|
|
from ansible.plugins.lookup import LookupBase
|
|
|
|
from utils.manager.credential_key import OVERRIDE_SECTION
|
|
from utils.roles.applications.config import get
|
|
from utils.roles.entity.name import entity_name
|
|
from utils.tls_common import resolve_enabled
|
|
|
|
OBJSTORE_ENGINES = ("seaweedfs", "minio")
|
|
OBJSTORE_PROVIDER_ROLE = {
|
|
"seaweedfs": "web-svc-seaweedfs",
|
|
"minio": "web-app-minio",
|
|
}
|
|
OBJSTORE_DEFAULT_PORT = {"seaweedfs": 8333, "minio": 9000}
|
|
OBJSTORE_DEFAULT_IMAGE = {
|
|
"seaweedfs": "chrislusf/seaweedfs",
|
|
"minio": "quay.io/minio/minio",
|
|
}
|
|
OBJSTORE_DEFAULT_REGION = "us-east-1"
|
|
|
|
|
|
def _is_enabled(applications: dict[str, Any], consumer_id: str, engine: str) -> bool:
|
|
return bool(
|
|
get(
|
|
applications,
|
|
consumer_id,
|
|
f"services.{engine}.enabled",
|
|
strict=False,
|
|
default=False,
|
|
)
|
|
)
|
|
|
|
|
|
class LookupModule(LookupBase):
|
|
"""
|
|
Resolve engine-agnostic S3 object-store values for a consumer.
|
|
|
|
Mirrors plugins/lookup/database.py (postgres|mariadb): branches on which
|
|
object-store engine (seaweedfs|minio) the consumer has enabled and yields a
|
|
uniform S3 connection payload, so consumer templates stay engine-agnostic.
|
|
|
|
API (STRICT):
|
|
- {{ lookup('objstore', consumer_id) }}
|
|
- {{ lookup('objstore', consumer_id, 'url') }}
|
|
"""
|
|
|
|
def run(
|
|
self,
|
|
terms: list[Any],
|
|
variables: dict[str, Any] | None = None,
|
|
**kwargs: Any,
|
|
) -> list[Any]:
|
|
terms = terms or []
|
|
if len(terms) not in (1, 2):
|
|
raise AnsibleError("objstore: requires consumer_id [, want_path]")
|
|
|
|
consumer_id = str(terms[0]).strip()
|
|
if not consumer_id:
|
|
raise AnsibleError("objstore: consumer_id must not be empty")
|
|
|
|
want = str(terms[1]).strip() if len(terms) == 2 else ""
|
|
if not want:
|
|
want = "all"
|
|
|
|
vars_ = variables or self._templar.available_variables
|
|
applications = lookup_loader.get(
|
|
"applications",
|
|
loader=self._loader,
|
|
templar=getattr(self, "_templar", None),
|
|
).run([], variables=vars_)[0]
|
|
path_instances = self._require_var(vars_, "DIR_COMPOSITIONS")
|
|
consumer_entity = entity_name(consumer_id)
|
|
|
|
active = [
|
|
e for e in OBJSTORE_ENGINES if _is_enabled(applications, consumer_id, e)
|
|
]
|
|
if len(active) > 1:
|
|
raise AnsibleError(
|
|
f"objstore: multiple object-store engines enabled for "
|
|
f"'{consumer_id}': {active}; enable only one."
|
|
)
|
|
engine = active[0] if active else ""
|
|
|
|
if not engine:
|
|
resolved = {
|
|
"id": "",
|
|
"engine": "",
|
|
"enabled": False,
|
|
"shared": False,
|
|
"local": False,
|
|
"host": "",
|
|
"port": "",
|
|
"network": "",
|
|
"container": "",
|
|
"bucket": consumer_entity,
|
|
"public": False,
|
|
"access_key": consumer_entity,
|
|
"secret_key": "",
|
|
"image": "",
|
|
"version": "",
|
|
"region": OBJSTORE_DEFAULT_REGION,
|
|
"env": "",
|
|
"volume": "",
|
|
"endpoint": "",
|
|
"url": "",
|
|
"credentialed_url": "",
|
|
"public_domain": "",
|
|
"public_bucket": "",
|
|
"public_endpoint": "",
|
|
"public_url": "",
|
|
"reach_host": "127.0.0.1",
|
|
}
|
|
return [resolved if want == "all" else resolved.get(want, "")]
|
|
|
|
provider_role = OBJSTORE_PROVIDER_ROLE[engine]
|
|
shared = bool(
|
|
get(
|
|
applications,
|
|
consumer_id,
|
|
f"services.{engine}.shared",
|
|
strict=False,
|
|
default=False,
|
|
)
|
|
)
|
|
central_enabled = shared
|
|
|
|
central_name = get(
|
|
applications,
|
|
provider_role,
|
|
f"services.{engine}.name",
|
|
strict=False,
|
|
default=engine,
|
|
skip_missing_app=True,
|
|
)
|
|
central_name = (
|
|
str(central_name) if central_name is not None else ""
|
|
).strip() or engine
|
|
|
|
host = central_name if central_enabled else engine
|
|
network = entity_name(provider_role) if central_enabled else consumer_entity
|
|
container = central_name if central_enabled else f"{consumer_entity}-{engine}"
|
|
port = get(
|
|
applications,
|
|
provider_role,
|
|
f"services.{engine}.api_port",
|
|
strict=False,
|
|
default=OBJSTORE_DEFAULT_PORT[engine],
|
|
skip_missing_app=True,
|
|
)
|
|
region = get(
|
|
applications,
|
|
provider_role,
|
|
f"services.{engine}.region",
|
|
strict=False,
|
|
default=OBJSTORE_DEFAULT_REGION,
|
|
skip_missing_app=True,
|
|
)
|
|
|
|
access_key = consumer_entity
|
|
secret_key = get(
|
|
applications,
|
|
consumer_id,
|
|
f"{OVERRIDE_SECTION}.objstore_secret_key",
|
|
strict=False,
|
|
default="",
|
|
)
|
|
bucket = consumer_entity
|
|
public = bool(
|
|
get(
|
|
applications,
|
|
consumer_id,
|
|
f"services.{engine}.public",
|
|
strict=False,
|
|
default=False,
|
|
)
|
|
)
|
|
|
|
image = get(
|
|
applications,
|
|
provider_role,
|
|
f"services.{engine}.image",
|
|
strict=False,
|
|
default=OBJSTORE_DEFAULT_IMAGE[engine],
|
|
skip_missing_app=True,
|
|
)
|
|
default_version = get(
|
|
applications,
|
|
provider_role,
|
|
f"services.{engine}.version",
|
|
strict=False,
|
|
default="latest",
|
|
skip_missing_app=True,
|
|
)
|
|
version = get(
|
|
applications,
|
|
consumer_id,
|
|
f"services.{engine}.version",
|
|
strict=False,
|
|
default=default_version,
|
|
)
|
|
|
|
env = f"{path_instances}{consumer_entity}/.env/objstore.env"
|
|
volume_prefix = "" if central_enabled else f"{consumer_entity}_"
|
|
volume = (
|
|
f"{volume_prefix}{engine}"
|
|
if bool(_is_enabled(applications, consumer_id, engine) and not shared)
|
|
else ""
|
|
)
|
|
endpoint = f"{host}:{port}"
|
|
url = f"http://{endpoint}"
|
|
userinfo = f"{quote(access_key, safe='')}:{quote(secret_key, safe='')}"
|
|
credentialed_url = f"http://{userinfo}@{endpoint}"
|
|
|
|
public_domain = get(
|
|
applications,
|
|
provider_role,
|
|
"domains.canonical.api",
|
|
strict=False,
|
|
default="",
|
|
skip_missing_app=True,
|
|
)
|
|
public_domain = (
|
|
str(public_domain) if public_domain is not None else ""
|
|
).strip()
|
|
public_bucket = f"{public_domain}/{bucket}" if public_domain else ""
|
|
provider_app = applications.get(provider_role) or {}
|
|
if not isinstance(provider_app, dict):
|
|
provider_app = {}
|
|
scheme = (
|
|
"https"
|
|
if resolve_enabled(provider_app, bool(vars_.get("TLS_ENABLED", True)))
|
|
else "http"
|
|
)
|
|
public_endpoint = f"{scheme}://{public_domain}" if public_domain else ""
|
|
public_url = f"{scheme}://{public_bucket}" if public_bucket else ""
|
|
|
|
resolved = {
|
|
"id": provider_role,
|
|
"engine": engine,
|
|
"enabled": True,
|
|
"shared": shared,
|
|
"local": bool(not shared),
|
|
"host": host,
|
|
"port": port,
|
|
"network": network,
|
|
"container": container,
|
|
"bucket": bucket,
|
|
"public": public,
|
|
"access_key": access_key,
|
|
"secret_key": secret_key,
|
|
"image": image,
|
|
"version": version,
|
|
"region": region,
|
|
"env": env,
|
|
"volume": volume,
|
|
"endpoint": endpoint,
|
|
"url": url,
|
|
"credentialed_url": credentialed_url,
|
|
"public_domain": public_domain,
|
|
"public_bucket": public_bucket,
|
|
"public_endpoint": public_endpoint,
|
|
"public_url": public_url,
|
|
"reach_host": "127.0.0.1",
|
|
}
|
|
return [resolved if want == "all" else resolved.get(want, "")]
|
|
|
|
@staticmethod
|
|
def _require_var(vars_: dict[str, Any], key: str) -> Any:
|
|
if key not in vars_:
|
|
raise AnsibleError(f"objstore: required variable '{key}' is not set")
|
|
return vars_[key]
|