From 41c1f41ed2b4ceb32ecd5f855ccd2ed5292a7f17 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 14 Apr 2025 16:41:49 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20authenticated=20reco?= =?UTF-8?q?rding=20file=20access=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement secure recording file access through authentication instead of exposing S3 bucket or using temporary signed links with loose permissions. Inspired by docs and @spaccoud's implementation, with comprehensive viewset checks to prevent unauthorized recording downloads. The ingress reserved to media intercept the original request, and thanks to Nginx annotations, check with the backend if the user is allowed to donwload this recording file. This might introduce a dependency to Nginx in the project by the way. Note: Tests are integration-based rather than unit tests, requiring minio in the compose stack and CI environment. Implementation includes known botocore deprecation warnings that per GitHub issues won't be resolved for months. --- .github/workflows/meet.yml | 30 ++ compose.yml | 33 ++ env.d/development/common.dist | 3 + src/backend/core/api/viewsets.py | 86 +++++- src/backend/core/enums.py | 12 + .../test_api_recordings_media_auth.py | 284 ++++++++++++++++++ src/backend/core/utils.py | 32 ++ .../dev-keycloak/values.meet.yaml.gotmpl | 14 + src/helm/env.d/dev/values.meet.yaml.gotmpl | 14 + src/helm/meet/README.md | 72 +++-- src/helm/meet/templates/ingress_media.yaml | 83 +++++ src/helm/meet/templates/media_svc.yaml | 14 + src/helm/meet/values.yaml | 42 +++ 13 files changed, 690 insertions(+), 29 deletions(-) create mode 100644 src/backend/core/tests/recording/test_api_recordings_media_auth.py create mode 100644 src/helm/meet/templates/ingress_media.yaml create mode 100644 src/helm/meet/templates/media_svc.yaml 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