diff --git a/plugins/lookup/objstore.py b/plugins/lookup/objstore.py index 8e47e743fb..297a53fa86 100644 --- a/plugins/lookup/objstore.py +++ b/plugins/lookup/objstore.py @@ -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", } diff --git a/roles/web-app-decidim/templates/env.j2 b/roles/web-app-decidim/templates/env.j2 index 50a388ec13..f7c5185d08 100644 --- a/roles/web-app-decidim/templates/env.j2 +++ b/roles/web-app-decidim/templates/env.j2 @@ -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 %} diff --git a/roles/web-app-funkwhale/meta/services.yml b/roles/web-app-funkwhale/meta/services.yml index 08753541cd..476aef14c4 100644 --- a/roles/web-app-funkwhale/meta/services.yml +++ b/roles/web-app-funkwhale/meta/services.yml @@ -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 diff --git a/roles/web-app-funkwhale/meta/volumes.yml b/roles/web-app-funkwhale/meta/volumes.yml index 83701d2555..c41937e1ec 100644 --- a/roles/web-app-funkwhale/meta/volumes.yml +++ b/roles/web-app-funkwhale/meta/volumes.yml @@ -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 }}" diff --git a/roles/web-app-funkwhale/tasks/main.yml b/roles/web-app-funkwhale/tasks/main.yml index 2e1413f963..f9b955f894 100644 --- a/roles/web-app-funkwhale/tasks/main.yml +++ b/roles/web-app-funkwhale/tasks/main.yml @@ -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') }}" diff --git a/roles/web-app-funkwhale/templates/env.j2 b/roles/web-app-funkwhale/templates/env.j2 index 763fc8c810..47fba917ff 100644 --- a/roles/web-app-funkwhale/templates/env.j2 +++ b/roles/web-app-funkwhale/templates/env.j2 @@ -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}} diff --git a/roles/web-app-funkwhale/templates/nginx.conf.j2 b/roles/web-app-funkwhale/templates/nginx.conf.j2 new file mode 100644 index 0000000000..1f989d0b9f --- /dev/null +++ b/roles/web-app-funkwhale/templates/nginx.conf.j2 @@ -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; + } + +} diff --git a/roles/web-app-funkwhale/vars/main.yml b/roles/web-app-funkwhale/vars/main.yml index 780a64c15a..81ffb20d02 100644 --- a/roles/web-app-funkwhale/vars/main.yml +++ b/roles/web-app-funkwhale/vars/main.yml @@ -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 }}" diff --git a/roles/web-svc-seaweedfs/meta/services.yml b/roles/web-svc-seaweedfs/meta/services.yml index dba84f2795..6dedfb7d7e 100644 --- a/roles/web-svc-seaweedfs/meta/services.yml +++ b/roles/web-svc-seaweedfs/meta/services.yml @@ -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: diff --git a/tests/unit/python/plugins/lookup/test_objstore.py b/tests/unit/python/plugins/lookup/test_objstore.py index 0135e5750f..08a63f4fc8 100644 --- a/tests/unit/python/plugins/lookup/test_objstore.py +++ b/tests/unit/python/plugins/lookup/test_objstore.py @@ -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": {