diff --git a/.github/workflows/meet.yml b/.github/workflows/meet.yml index 0d2c5618..957ac9a2 100644 --- a/.github/workflows/meet.yml +++ b/.github/workflows/meet.yml @@ -135,6 +135,9 @@ jobs: STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage LIVEKIT_API_SECRET: secret LIVEKIT_API_KEY: devkey + AWS_S3_ENDPOINT_URL: http://localhost:9000 + AWS_S3_ACCESS_KEY_ID: meet + AWS_S3_SECRET_ACCESS_KEY: password steps: - name: Checkout repository @@ -152,6 +155,33 @@ jobs: path: "src/backend/core/templates/mail" key: mail-templates-${{ hashFiles('src/mail/mjml') }} + - name: Start MinIO + run: | + docker pull minio/minio + docker run -d --name minio \ + -p 9000:9000 \ + -e "MINIO_ACCESS_KEY=meet" \ + -e "MINIO_SECRET_KEY=password" \ + -v /data/media:/data \ + minio/minio server --console-address :9001 /data + + # Tool to wait for a service to be ready + - name: Install Dockerize + run: | + curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv + + - name: Wait for MinIO to be ready + run: | + dockerize -wait tcp://localhost:9000 -timeout 10s + + - name: Configure MinIO + run: | + MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/') + docker exec ${MINIO} sh -c \ + "mc alias set meet http://localhost:9000 meet password && \ + mc alias ls && \ + mc mb meet/meet-media-storage" + - name: Install Python uses: actions/setup-python@v5 with: diff --git a/compose.yml b/compose.yml index 8eb9e204..1a1115d7 100644 --- a/compose.yml +++ b/compose.yml @@ -15,6 +15,37 @@ services: ports: - "1081:1080" + minio: + user: ${DOCKER_USER:-1000} + image: minio/minio + environment: + - MINIO_ROOT_USER=meet + - MINIO_ROOT_PASSWORD=password + ports: + - '9000:9000' + - '9001:9001' + healthcheck: + test: [ "CMD", "mc", "ready", "local" ] + interval: 1s + timeout: 20s + retries: 300 + entrypoint: "" + command: minio server --console-address :9001 /data + volumes: + - ./data/media:/data + + createbuckets: + image: minio/mc + depends_on: + minio: + condition: service_healthy + restart: true + entrypoint: > + sh -c " + /usr/bin/mc alias set meet http://minio:9000 meet password && \ + /usr/bin/mc mb meet/meet-media-storage && \ + exit 0;" + app-dev: build: context: . @@ -40,6 +71,7 @@ services: - redis - nginx - livekit + - createbuckets celery-dev: user: ${DOCKER_USER:-1000} @@ -73,6 +105,7 @@ services: - postgresql - redis - livekit + - minio celery: user: ${DOCKER_USER:-1000} diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 3085e716..9d3cf441 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -22,6 +22,9 @@ MEET_BASE_URL="http://localhost:8072" # Media STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage +AWS_S3_ENDPOINT_URL=http://minio:9000 +AWS_S3_ACCESS_KEY_ID=meet +AWS_S3_SECRET_ACCESS_KEY=password # OIDC OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 61c384ef..25912105 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2,6 +2,7 @@ import uuid from logging import getLogger +from urllib.parse import urlparse from django.conf import settings from django.db.models import Q @@ -20,7 +21,8 @@ from rest_framework import ( status as drf_status, ) -from core import models, utils +from core import enums, models, utils +from core.recording.enums import FileExtension from core.recording.event.authentication import StorageEventAuthentication from core.recording.event.exceptions import ( InvalidBucketError, @@ -618,3 +620,85 @@ class RecordingViewSet( return drf_response.Response( {"message": "Event processed."}, ) + + def _auth_get_original_url(self, request): + """ + Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header. + Raises PermissionDenied if the header is missing. + The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header. + See corresponding ingress configuration in Helm chart and read about the + nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress + is configured to do this. + Based on the original url and the logged-in user, we must decide if we authorize Nginx + to let this request go through (by returning a 200 code) or if we block it (by returning + a 403 error). Note that we return 403 errors without any further details for security + reasons. + """ + # Extract the original URL from the request header + original_url = request.META.get("HTTP_X_ORIGINAL_URL") + if not original_url: + logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest") + raise drf_exceptions.PermissionDenied() + + logger.debug("Original url: '%s'", original_url) + return urlparse(original_url) + + def _auth_get_url_params(self, pattern, fragment): + """ + Extracts URL parameters from the given fragment using the specified regex pattern. + Raises PermissionDenied if parameters cannot be extracted. + """ + + match = pattern.search(fragment) + + try: + return match.groupdict() + except (ValueError, AttributeError) as exc: + logger.debug("Failed to extract parameters from subrequest URL: %s", exc) + raise drf_exceptions.PermissionDenied() from exc + + @decorators.action(detail=False, methods=["get"], url_path="media-auth") + def media_auth(self, request, *args, **kwargs): + """ + This view is used by an Nginx subrequest to control access to a recording's + media file. + When we let the request go through, we compute authorization headers that will be added to + the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers + annotation. The request will then be proxied to the object storage backend who will + respond with the file after checking the signature included in headers. + """ + + parsed_url = self._auth_get_original_url(request) + + url_params = self._auth_get_url_params( + enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path + ) + + user = request.user + recording_id = url_params["recording_id"] + + extension = url_params["extension"] + if extension not in [item.value for item in FileExtension]: + raise drf_exceptions.ValidationError({"detail": "Unsupported extension."}) + + try: + recording = models.Recording.objects.get(id=recording_id) + except models.Recording.DoesNotExist as e: + raise drf_exceptions.NotFound("No recording found for this event.") from e + + if extension != recording.extension: + raise drf_exceptions.NotFound("No recording found with this extension.") + + abilities = recording.get_abilities(user) + + if not abilities["retrieve"]: + logger.debug("User '%s' lacks permission for attachment", user.id) + raise drf_exceptions.PermissionDenied() + + if not recording.is_saved: + logger.debug("Recording '%s' has not been saved", recording) + raise drf_exceptions.PermissionDenied() + + request = utils.generate_s3_authorization_headers(recording.key) + + return drf_response.Response("authorized", headers=request.headers, status=200) diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index e67d7b5b..2e3fa593 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -2,9 +2,21 @@ Core application enums declaration """ +import re + from django.conf import global_settings, settings from django.utils.translation import gettext_lazy as _ +UUID_REGEX = ( + r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" +) +FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}" + +# pylint: disable=line-too-long +RECORDING_STORAGE_URL_PATTERN = re.compile( + f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P{UUID_REGEX:s}).(?P{FILE_EXT_REGEX:s})" +) + # Django sets `LANGUAGES` by default with all supported languages. We can use it for # the choice of languages which should not be limited to the few languages active in # the app. diff --git a/src/backend/core/tests/recording/test_api_recordings_media_auth.py b/src/backend/core/tests/recording/test_api_recordings_media_auth.py new file mode 100644 index 00000000..3a860fbf --- /dev/null +++ b/src/backend/core/tests/recording/test_api_recordings_media_auth.py @@ -0,0 +1,284 @@ +""" +Test media-auth authorization API endpoint in docs core app. +""" + +from io import BytesIO +from urllib.parse import urlparse +from uuid import uuid4 + +from django.conf import settings +from django.core.files.storage import default_storage +from django.utils import timezone + +import pytest +import requests +from rest_framework.test import APIClient + +from core import models +from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFactory + +pytestmark = pytest.mark.django_db + + +def test_api_documents_media_auth_unauthenticated(): + """ + Test that unauthenticated requests to download media are rejected + """ + original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4" + + response = APIClient().get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 401 + + +def test_api_documents_media_auth_wrong_path(): + """ + Test that media URLs with incorrect path structures are rejected + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + original_url = f"http://localhost/media/wrong-path/{uuid4()!s}.mp4" + + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 + + +def test_api_documents_media_auth_unknown_recording(): + """ + Test that requests for non-existent recordings are properly handled + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4" + + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 404 + + +def test_api_documents_media_auth_no_abilities(): + """ + Test that users without any access permissions cannot download recordings + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED) + original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4" + + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 + + +def test_api_documents_media_auth_wrong_abilities(): + """ + Test that users with insufficient role permissions cannot download recordings + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED) + + UserRecordingAccessFactory(user=user, recording=recording, role="member") + + original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4" + + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 + + +@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"]) +def test_api_documents_media_auth_unsaved(wrong_status): + """ + Test that recordings that aren't in 'saved' status cannot be downloaded + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + recording = RecordingFactory(status=wrong_status) + UserRecordingAccessFactory(user=user, recording=recording, role="owner") + + original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4" + + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 + + +def test_api_documents_media_auth_mismatched_extension(): + """ + Test that requests with mismatched file extensions are rejected + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + recording = RecordingFactory( + status=models.RecordingStatusChoices.SAVED, + mode=models.RecordingModeChoices.TRANSCRIPT, + ) + UserRecordingAccessFactory(user=user, recording=recording, role="owner") + + original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4" + + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "No recording found with this extension."} + + +@pytest.mark.parametrize( + "wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"] +) +def test_api_documents_media_auth_wrong_extension(wrong_extension): + """ + Trying to download a recording with an unsupported extension should return + a validation error (400) with details about allowed extensions. + """ + user = UserFactory() + client = APIClient() + client.force_login(user) + + recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED) + UserRecordingAccessFactory(user=user, recording=recording, role="owner") + + original_url = ( + f"http://localhost/media/recordings/{recording.id!s}.{wrong_extension}" + ) + + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 400 + assert response.json() == {"detail": "Unsupported extension."} + + +@pytest.mark.parametrize("mode", ["screen_recording", "transcript"]) +def test_api_documents_media_auth_success_owner(mode): + """ + Test downloading a recording when logged in and authorized. + Verifies S3 authentication headers and successful file retrieval. + """ + user = UserFactory() + client = APIClient() + client.force_login(user) + + recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode) + UserRecordingAccessFactory(user=user, recording=recording, role="owner") + + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=recording.key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + ) + + original_url = f"http://localhost/media/{recording.key:s}" + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert ( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" + in authorization + ) + assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}" + response = requests.get( + file_url, + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content.decode("utf-8") == "my prose" + + +@pytest.mark.parametrize("mode", ["screen_recording", "transcript"]) +def test_api_documents_media_auth_success_administrator(mode): + """ + Test downloading a recording when logged in and authorized. + Verifies S3 authentication headers and successful file retrieval. + """ + user = UserFactory() + client = APIClient() + client.force_login(user) + + recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode) + UserRecordingAccessFactory(user=user, recording=recording, role="administrator") + + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=recording.key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + ) + + original_url = f"http://localhost/media/{recording.key:s}" + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert ( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" + in authorization + ) + assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}" + response = requests.get( + file_url, + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content.decode("utf-8") == "my prose" diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 4d1d3806..bcd75506 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -11,7 +11,9 @@ from typing import Optional from uuid import uuid4 from django.conf import settings +from django.core.files.storage import default_storage +import botocore from livekit.api import AccessToken, VideoGrants @@ -110,3 +112,33 @@ def generate_livekit_config( room=room_id, user=user, username=username, color=color ), } + + +def generate_s3_authorization_headers(key): + """ + Generate authorization headers for an s3 object. + These headers can be used as an alternative to signed urls with many benefits: + - the urls of our files never expire and can be stored in our recording' metadata + - we don't leak authorized urls that could be shared (file access can only be done + with cookies) + - access control is truly realtime + - the object storage service does not need to be exposed on internet + """ + + url = default_storage.unsigned_connection.meta.client.generate_presigned_url( + "get_object", + ExpiresIn=0, + Params={"Bucket": default_storage.bucket_name, "Key": key}, + ) + + request = botocore.awsrequest.AWSRequest(method="get", url=url) + + s3_client = default_storage.connection.meta.client + # pylint: disable=protected-access + credentials = s3_client._request_signer._credentials # noqa: SLF001 + frozen_credentials = credentials.get_frozen_credentials() + region = s3_client.meta.region_name + auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region) + auth.add_auth(request) + + return request diff --git a/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl b/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl index 306e2e20..bed3f57c 100644 --- a/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl +++ b/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl @@ -200,3 +200,17 @@ celery: - "worker" - "--pool=solo" - "--loglevel=info" + +ingressMedia: + enabled: true + host: meet.127.0.0.1.nip.io + + annotations: + nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/ + nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256" + nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000 + nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1 + +serviceMedia: + host: minio.meet.svc.cluster.local + port: 9000 diff --git a/src/helm/env.d/dev/values.meet.yaml.gotmpl b/src/helm/env.d/dev/values.meet.yaml.gotmpl index 2e4d4ac5..cdd86239 100644 --- a/src/helm/env.d/dev/values.meet.yaml.gotmpl +++ b/src/helm/env.d/dev/values.meet.yaml.gotmpl @@ -228,3 +228,17 @@ celery: - "worker" - "--pool=solo" - "--loglevel=info" + +ingressMedia: + enabled: true + host: meet.127.0.0.1.nip.io + + annotations: + nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/ + nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256" + nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000 + nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1 + +serviceMedia: + host: minio.meet.svc.cluster.local + port: 9000 diff --git a/src/helm/meet/README.md b/src/helm/meet/README.md index bac51083..f375c33d 100644 --- a/src/helm/meet/README.md +++ b/src/helm/meet/README.md @@ -4,34 +4,50 @@ ### General configuration -| Name | Description | Value | -| ------------------------------------------ | ---------------------------------------------------- | ---------------------- | -| `image.repository` | Repository to use to pull meet's container image | `lasuite/meet-backend` | -| `image.tag` | meet's container tag | `latest` | -| `image.pullPolicy` | Container image pull policy | `IfNotPresent` | -| `image.credentials.username` | Username for container registry authentication | | -| `image.credentials.password` | Password for container registry authentication | | -| `image.credentials.registry` | Registry url for which the credentials are specified | | -| `image.credentials.name` | Name of the generated secret for imagePullSecrets | | -| `nameOverride` | Override the chart name | `""` | -| `fullnameOverride` | Override the full application name | `""` | -| `ingress.enabled` | whether to enable the Ingress or not | `false` | -| `ingress.className` | IngressClass to use for the Ingress | `nil` | -| `ingress.host` | Host for the Ingress | `meet.example.com` | -| `ingress.path` | Path to use for the Ingress | `/` | -| `ingress.hosts` | Additional host to configure for the Ingress | `[]` | -| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` | -| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | | -| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | | -| `ingress.customBackends` | Add custom backends to ingress | `[]` | -| `ingressAdmin.enabled` | whether to enable the Ingress or not | `false` | -| `ingressAdmin.className` | IngressClass to use for the Ingress | `nil` | -| `ingressAdmin.host` | Host for the Ingress | `meet.example.com` | -| `ingressAdmin.path` | Path to use for the Ingress | `/admin` | -| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` | -| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` | -| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | | -| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | | +| Name | Description | Value | +| ---------------------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------- | +| `image.repository` | Repository to use to pull meet's container image | `lasuite/meet-backend` | +| `image.tag` | meet's container tag | `latest` | +| `image.pullPolicy` | Container image pull policy | `IfNotPresent` | +| `image.credentials.username` | Username for container registry authentication | | +| `image.credentials.password` | Password for container registry authentication | | +| `image.credentials.registry` | Registry url for which the credentials are specified | | +| `image.credentials.name` | Name of the generated secret for imagePullSecrets | | +| `nameOverride` | Override the chart name | `""` | +| `fullnameOverride` | Override the full application name | `""` | +| `ingress.enabled` | whether to enable the Ingress or not | `false` | +| `ingress.className` | IngressClass to use for the Ingress | `nil` | +| `ingress.host` | Host for the Ingress | `meet.example.com` | +| `ingress.path` | Path to use for the Ingress | `/` | +| `ingress.hosts` | Additional host to configure for the Ingress | `[]` | +| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` | +| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | | +| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | | +| `ingress.customBackends` | Add custom backends to ingress | `[]` | +| `ingressAdmin.enabled` | whether to enable the Ingress or not | `false` | +| `ingressAdmin.className` | IngressClass to use for the Ingress | `nil` | +| `ingressAdmin.host` | Host for the Ingress | `meet.example.com` | +| `ingressAdmin.path` | Path to use for the Ingress | `/admin` | +| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` | +| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` | +| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | | +| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | | +| `ingressMedia.enabled` | whether to enable the Ingress or not | `false` | +| `ingressMedia.className` | IngressClass to use for the Ingress | `nil` | +| `ingressMedia.host` | Host for the Ingress | `meet.example.com` | +| `ingressMedia.path` | Path to use for the Ingress | `/media/(.*)` | +| `ingressMedia.hosts` | Additional host to configure for the Ingress | `[]` | +| `ingressMedia.tls.enabled` | Weather to enable TLS for the Ingress | `true` | +| `ingressMedia.tls.secretName` | Secret name for TLS config | `nil` | +| `ingressMedia.tls.additional[].secretName` | Secret name for additional TLS config | | +| `ingressMedia.tls.additional[].hosts[]` | Hosts for additional TLS config | | +| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url` | | `https://meet.example.com/api/v1.0/recordings/media-auth/` | +| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers` | | `Authorization, X-Amz-Date, X-Amz-Content-SHA256` | +| `ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost` | | `minio.meet.svc.cluster.local:9000` | +| `ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet` | | `add_header Content-Security-Policy "default-src 'none'" always;` | +| `serviceMedia.host` | | `minio.meet.svc.cluster.local` | +| `serviceMedia.port` | | `9000` | +| `serviceMedia.annotations` | | `{}` | ### backend diff --git a/src/helm/meet/templates/ingress_media.yaml b/src/helm/meet/templates/ingress_media.yaml new file mode 100644 index 00000000..5ca6f0c6 --- /dev/null +++ b/src/helm/meet/templates/ingress_media.yaml @@ -0,0 +1,83 @@ +{{- if .Values.ingressMedia.enabled -}} +{{- $fullName := include "meet.fullname" . -}} +{{- if and .Values.ingressMedia.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingressMedia.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingressMedia.annotations "kubernetes.io/ingress.class" .Values.ingressMedia.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }}-media + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "meet.labels" . | nindent 4 }} + {{- with .Values.ingressMedia.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingressMedia.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingressMedia.className }} + {{- end }} + {{- if .Values.ingressMedia.tls.enabled }} + tls: + {{- if .Values.ingressMedia.host }} + - secretName: {{ .Values.ingressMedia.tls.secretName | default (printf "%s-tls" $fullName) | quote }} + hosts: + - {{ .Values.ingressMedia.host | quote }} + {{- end }} + {{- range .Values.ingressMedia.tls.additional }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- if .Values.ingressMedia.host }} + - host: {{ .Values.ingressMedia.host | quote }} + http: + paths: + - path: {{ .Values.ingressMedia.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: ImplementationSpecific + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }}-media + port: + number: {{ .Values.serviceMedia.port }} + {{- else }} + serviceName: {{ $fullName }}-media + servicePort: {{ .Values.serviceMedia.port }} + {{- end }} + {{- end }} + {{- range .Values.ingressMedia.hosts }} + - host: {{ . | quote }} + http: + paths: + - path: {{ $.Values.ingressMedia.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: ImplementationSpecific + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }}-media + port: + number: {{ .Values.serviceMedia.port }} + {{- else }} + serviceName: {{ $fullName }}-media + servicePort: {{ .Values.serviceMedia.port }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/src/helm/meet/templates/media_svc.yaml b/src/helm/meet/templates/media_svc.yaml new file mode 100644 index 00000000..d1d3b843 --- /dev/null +++ b/src/helm/meet/templates/media_svc.yaml @@ -0,0 +1,14 @@ +{{- $fullName := include "meet.fullname" . -}} +{{- $component := "media" -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-media + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "meet.common.labels" (list . $component) | nindent 4 }} + annotations: + {{- toYaml $.Values.serviceMedia.annotations | nindent 4 }} +spec: + type: ExternalName + externalName: {{ $.Values.serviceMedia.host }} diff --git a/src/helm/meet/values.yaml b/src/helm/meet/values.yaml index 3acd0756..34d2e100 100644 --- a/src/helm/meet/values.yaml +++ b/src/helm/meet/values.yaml @@ -69,6 +69,48 @@ ingressAdmin: enabled: true additional: [] +## @param ingressMedia.enabled whether to enable the Ingress or not +## @param ingressMedia.className IngressClass to use for the Ingress +## @param ingressMedia.host Host for the Ingress +## @param ingressMedia.path Path to use for the Ingress +ingressMedia: + enabled: false + className: null + host: meet.example.com + path: /media/(.*) + ## @param ingressMedia.hosts Additional host to configure for the Ingress + hosts: [ ] + # - chart-example.local + ## @param ingressMedia.tls.enabled Weather to enable TLS for the Ingress + ## @param ingressMedia.tls.secretName Secret name for TLS config + ## @skip ingressMedia.tls.additional + ## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config + ## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config + tls: + enabled: true + secretName: null + additional: [] + + ## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url + ## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers + ## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost + ## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet + annotations: + nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/recordings/media-auth/ + nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256" + nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000 + nginx.ingress.kubernetes.io/configuration-snippet: | + add_header Content-Security-Policy "default-src 'none'" always; + +## @param serviceMedia.host +## @param serviceMedia.port +## @param serviceMedia.annotations +serviceMedia: + host: minio.meet.svc.cluster.local + port: 9000 + annotations: {} + + ## @section backend