fix(objstore): sign funkwhale's media URLs against the host the browser dials

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>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-15 22:14:38 +02:00
1 parent 40001f0e9a
commit 1e61debad2
10 files changed
+203 -6

No files matched your search

+3
View File
@@ -112,6 +112,7 @@ class LookupModule(LookupBase):
"credentialed_url": "",
"public_domain": "",
"public_bucket": "",
"public_endpoint": "",
"public_url": "",
"reach_host": "127.0.0.1",
}
@@ -236,6 +237,7 @@ class LookupModule(LookupBase):
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 = {
@@ -262,6 +264,7 @@ class LookupModule(LookupBase):
"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",
}
+1 -1
View File
@@ -13,7 +13,7 @@ STORAGE_PROVIDER=s3
AWS_BUCKET={{ lookup('objstore', application_id, 'bucket') }}
AWS_ACCESS_KEY_ID={{ lookup('objstore', application_id, 'access_key') }}
AWS_SECRET_ACCESS_KEY={{ lookup('objstore', application_id, 'secret_key') }}
AWS_ENDPOINT={{ lookup('objstore', application_id, 'public_url') | regex_replace('/[^/]+/?$', '') }}
AWS_ENDPOINT={{ lookup('objstore', application_id, 'public_endpoint') }}
AWS_REGION={{ lookup('objstore', application_id, 'region') }}
AWS_PUBLIC={{ lookup('objstore', application_id, 'public') | string | lower }}
{% endif %}
+1 -1
View File
@@ -1,5 +1,5 @@
---
# nocheck: seaweedfs-playwright — Funkwhale writes to the bucket only through the audio-library import flow, which needs a persona-owned library and a real track upload the role does not provision; PROXY_MEDIA=true serves every read back through the API, so the bucket has no persona-reachable surface
# nocheck: seaweedfs-playwright — the only objects this role writes are the branding images of tasks/01_manager.yml, and files/playwright/test-design.js fetches the sidebar logo from its bucket URL and compares it byte for byte with the configured asset; on an onion pick that fetch fails, because web-svc-seaweedfs publishes no onion vhost and the Tor-proxied browser cannot resolve its clearnet name; the document surface the shared scenario drives is the audio-library import flow, which needs a persona-owned library and a real track upload the role does not provision
funkwhale:
healthcheck:
flavor: wget
+10
View File
@@ -41,6 +41,16 @@ static:
target: /usr/share/nginx/html/staticfiles
read_only: true
front_nginx_conf:
type: config
source: "{{ [lookup('container', 'web-app-funkwhale', 'directories.config'), 'nginx.conf'] | path_join }}"
mode: "0444"
mounts:
- service: front
target: /etc/nginx/templates/default.conf.template
when: "{{ (lookup('config', 'web-app-funkwhale', 'services.seaweedfs.enabled', false) | bool) or
(lookup('config', 'web-app-funkwhale', 'services.minio.enabled', false) | bool) }}"
design_admin_template:
type: config
source: "{{ [lookup('container', 'web-app-funkwhale', 'directories.config'), 'design', 'admin_base_site.html'] | path_join }}"
+10
View File
@@ -5,6 +5,16 @@
vars:
webserver_extra_configuration: "client_max_body_size 512M;"
- name: "🧱 Render the front nginx override of {{ application_id }}"
ansible.builtin.include_tasks:
file: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/render_replicated_templates.yml') }}"
vars:
role_templates:
- src: "nginx.conf.j2"
dest: "{{ FUNKWHALE_FRONT_NGINX_HOST }}"
mode: "0444"
when: FUNKWHALE_OBJSTORE_ENABLED | bool
- name: "🎨 Render the Django admin template of {{ application_id }}"
ansible.builtin.include_tasks:
file: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/render_replicated_templates.yml') }}"
+5 -1
View File
@@ -29,9 +29,13 @@ MEDIA_ROOT={{ FUNKWHALE_MEDIA_DIR }}
AWS_ACCESS_KEY_ID={{ lookup('objstore', application_id, 'access_key') }}
AWS_SECRET_ACCESS_KEY={{ lookup('objstore', application_id, 'secret_key') }}
AWS_STORAGE_BUCKET_NAME={{ lookup('objstore', application_id, 'bucket') }}
AWS_S3_ENDPOINT_URL={{ lookup('objstore', application_id, 'url') }}
AWS_S3_ENDPOINT_URL={{ lookup('objstore', application_id, 'public_endpoint') or undef(hint='objstore public_endpoint is empty for ' ~ application_id ~ ': the provider declares no domains.canonical.api, so no endpoint signs URLs the browser can reach') }}
AWS_S3_REGION_NAME={{ lookup('objstore', application_id, 'region') }}
AWS_QUERYSTRING_AUTH=True
PROXY_MEDIA=true
{% if lookup('ca_injected', application_id) %}
AWS_CA_BUNDLE={{ CA_TRUST.inject_bundle_container }}
{% endif %}
{% endif %}
STATIC_ROOT={{FUNKWHALE_STATIC_DIR}}
@@ -0,0 +1,141 @@
upstream funkwhale-api {
server ${FUNKWHALE_API_HOST}:${FUNKWHALE_API_PORT};
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
client_max_body_size ${NGINX_MAX_BODY_SIZE};
charset utf-8;
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
application/javascript
application/vnd.geo+json
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
font/opentype
image/bmp
image/svg+xml
image/x-icon
text/cache-manifest
text/css
text/plain
text/vcard
text/vnd.rim.location.xloc
text/vtt
text/x-component
text/x-cross-domain-policy;
add_header Content-Security-Policy "default-src 'self'; connect-src https: wss: http: ws: 'self' 'unsafe-eval'; script-src 'self' 'wasm-unsafe-eval'; style-src https: http: 'self' 'unsafe-inline'; img-src https: http: 'self' data:; font-src https: http: 'self' data:; media-src https: http: 'self' data:; object-src 'none'";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header X-Frame-Options "SAMEORIGIN" always; # nocheck: deprecated-http-header this is funkwhale's in-container front vhost, not the sys-svc-proxy vhost that owns the CSP SPOT
add_header Service-Worker-Allowed "/";
location /api/ {
include /etc/nginx/funkwhale_proxy.conf;
client_max_body_size ${NGINX_MAX_BODY_SIZE};
proxy_pass http://funkwhale-api;
}
location ~ ^/library/(albums|tracks|artists|playlists)/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api;
}
location /channels/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api;
}
location ~ ^/@(vite-plugin-pwa|vite|id)/ {
include /etc/nginx/funkwhale_proxy.conf;
alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html;
}
location /@ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api;
}
location / {
include /etc/nginx/funkwhale_proxy.conf;
alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html;
}
location ~ "/(front/)?embed.html" {
alias /usr/share/nginx/html/embed.html;
add_header Content-Security-Policy "connect-src https: http: 'self'; default-src 'self'; script-src 'self' unpkg.com 'unsafe-inline' 'unsafe-eval'; style-src https: http: 'self' 'unsafe-inline'; img-src https: http: 'self' data:; font-src https: http: 'self' data:; object-src 'none'; media-src https: http: 'self' data:";
add_header Referrer-Policy "strict-origin-when-cross-origin";
expires 1d;
}
location /federation/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api;
}
location /rest/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api/api/subsonic/rest/;
}
location /.well-known/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api;
}
location /media/__sized__/ {
alias ${MEDIA_ROOT}/__sized__/;
add_header Access-Control-Allow-Origin '*';
}
location /media/attachments/ {
alias ${MEDIA_ROOT}/attachments/;
add_header Access-Control-Allow-Origin '*';
}
location /media/dynamic_preferences/ {
alias ${MEDIA_ROOT}/dynamic_preferences/;
add_header Access-Control-Allow-Origin '*';
}
location ~ /_protected/media/(.+) {
internal;
{{ NETWORK_DOCKER_DNS_NGINX_DIRECTIVE }}
proxy_set_header Authorization "";
proxy_set_header Host $proxy_host;
proxy_ssl_server_name on;
proxy_pass $1;
add_header Access-Control-Allow-Origin '*';
}
location /_protected/music/ {
internal;
alias ${MUSIC_DIRECTORY_PATH}/;
add_header Access-Control-Allow-Origin '*';
}
location /manifest.json {
return 302 ${FUNKWHALE_PROTOCOL}://${FUNKWHALE_HOSTNAME}/api/v2/instance/spa-manifest.json;
}
}
+3
View File
@@ -10,6 +10,9 @@ FUNKWHALE_DOCKER_API_PORT: "{{ lookup('config', application_id, 'services.api.po
FUNKWHALE_TYPESENSE_API_KEY: "{{ lookup('config', application_id, 'secrets.credentials.api_key') }}"
FUNKWHALE_API_CONTAINER: "{{ lookup('config', application_id, 'services.api.name') }}"
FUNKWHALE_OBJSTORE_ENABLED: "{{ lookup('objstore', application_id, 'enabled') | bool }}"
FUNKWHALE_FRONT_NGINX_HOST: "{{ [lookup('container', application_id, 'directories.config'), 'nginx.conf'] | path_join }}"
FUNKWHALE_DESIGN_ENABLED: "{{ lookup('config', application_id, 'services.design.enabled') | bool }}"
FUNKWHALE_DESIGN_LOGO_ENABLED: "{{ FUNKWHALE_DESIGN_ENABLED | bool and lookup('design', application_id).logo is string }}"
FUNKWHALE_DESIGN_PALETTE: "{{ lookup('design_palette', lookup('config', 'web-svc-design', 'services.design.colors.base')).light }}"
+5 -3
View File
@@ -1,7 +1,9 @@
---
# nocheck: tor-contract — object-storage backend; consumed server-side over the
# internal network (matrix S3 media etc.), never a Tor-browser surface, so it
# rides no node onion. The browser consoles live in web-app-seaweedfs.
# nocheck: tor-contract — an onion vhost is not servable for this role: the
# node hidden service publishes http only (svc-prx-openresty
# ports.onion.https is false) while sys-front-tls/templates/nginx_ssl_header.j2
# keys listen/ssl on application_id rather than on the rendered domain, so the
# onion vhost would listen on 443 while Tor forwards only 80.
# nocheck: playwright-service-flag — central object-store provider entity
seaweedfs:
healthcheck:
@@ -104,6 +104,7 @@ class ObjstoreLookupTests(unittest.TestCase):
self.assertEqual(out["url"], "")
self.assertEqual(out["public_domain"], "")
self.assertEqual(out["public_bucket"], "")
self.assertEqual(out["public_endpoint"], "")
self.assertEqual(out["public_url"], "")
self.assertEqual(out["reach_host"], "127.0.0.1")
@@ -139,6 +140,7 @@ class ObjstoreLookupTests(unittest.TestCase):
self.assertEqual(out["url"], "http://seaweedfs-central:8334")
self.assertEqual(out["public_domain"], "api.seaweedfs.s3.example.com")
self.assertEqual(out["public_bucket"], "api.seaweedfs.s3.example.com/foo")
self.assertEqual(out["public_endpoint"], "https://api.seaweedfs.s3.example.com")
self.assertEqual(out["public_url"], "https://api.seaweedfs.s3.example.com/foo")
self.assertEqual(
@@ -146,6 +148,28 @@ class ObjstoreLookupTests(unittest.TestCase):
"http://seaweedfs-central:8334",
)
def test_named_canonical_siblings_do_not_win_over_the_api(self):
applications = {
"web-app-foo": {
"services": {"minio": {"enabled": True, "shared": True}},
"secrets": {"credentials": {"objstore_secret_key": "sk"}},
},
"web-app-minio": {
"services": {"minio": {"name": "minio-central"}},
"domains": {
"canonical": {
"console": "console.minio.s3.example.com",
"api": "api.minio.s3.example.com",
}
},
},
}
out = self._run(["web-app-foo"], applications)[0]
self.assertEqual(out["public_domain"], "api.minio.s3.example.com")
self.assertEqual(out["public_endpoint"], "https://api.minio.s3.example.com")
def test_credentialed_url_percent_encodes_both_halves(self):
applications = {
"web-app-foo": {