diff --git a/.github/workflows/vps-ci-cd.yml b/.github/workflows/vps-ci-cd.yml index bdbbbce..ba8702a 100644 --- a/.github/workflows/vps-ci-cd.yml +++ b/.github/workflows/vps-ci-cd.yml @@ -182,10 +182,50 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + publish_media_image: + name: Publish isolated media image + runs-on: ubuntu-latest + needs: [build, test] + if: github.event_name != 'pull_request' && needs.build.outputs.has_dotnet_project == 'true' + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.image.outputs.image }} + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve media tags + id: image + shell: bash + run: | + image="ghcr.io/${{ github.repository_owner }}/sonicrelay-media:sha-${GITHUB_SHA}" + echo "image=$image" >> "$GITHUB_OUTPUT" + { + echo 'tags<> "$GITHUB_OUTPUT" + - uses: docker/build-push-action@v6 + with: + context: . + file: ./MediaRelay.Dockerfile + push: true + tags: ${{ steps.image.outputs.tags }} + cache-from: type=gha,scope=media + cache-to: type=gha,scope=media,mode=max deploy: name: Deploy to VPS over SSH runs-on: ubuntu-latest - needs: publish_image + needs: [publish_image, publish_media_image] if: >- ${{ needs.publish_image.result == 'success' && @@ -306,13 +346,14 @@ jobs: uses: appleboy/ssh-action@v1 env: IMAGE: ${{ needs.publish_image.outputs.image }} + MEDIA_IMAGE: ${{ needs.publish_media_image.outputs.image }} APP_DIR: ${{ steps.ssh_target.outputs.app_dir }} with: host: ${{ steps.ssh_target.outputs.host }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} port: ${{ steps.ssh_target.outputs.port }} - envs: IMAGE,APP_DIR + envs: IMAGE,MEDIA_IMAGE,APP_DIR script: | cd "$APP_DIR" chmod +x deploy.sh efbundle diff --git a/MediaRelay.Dockerfile b/MediaRelay.Dockerfile new file mode 100644 index 0000000..5ea50c9 --- /dev/null +++ b/MediaRelay.Dockerfile @@ -0,0 +1,26 @@ +# syntax=docker/dockerfile:1.7 + +ARG DOTNET_MAJOR=10.0 +ARG APP_PROJECT=services/SonicRelay.MediaRelay/SonicRelay.MediaRelay.csproj + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_MAJOR} AS build +ARG APP_PROJECT +WORKDIR /src + +COPY . . +RUN dotnet restore "$APP_PROJECT" +RUN dotnet publish "$APP_PROJECT" \ + --configuration Release \ + --no-restore \ + --output /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_MAJOR} AS runtime +WORKDIR /app + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +COPY --from=build /app/publish ./ +USER $APP_UID + +ENTRYPOINT ["dotnet", "SonicRelay.MediaRelay.dll"] diff --git a/README.md b/README.md index 2a56151..871b80e 100644 --- a/README.md +++ b/README.md @@ -131,3 +131,23 @@ The automated deployment Compose file contains only RelayControl's API process. ## License See [LICENSE](LICENSE). + +### Feature switches + +RelayControl uses Microsoft.FeatureManagement. `FeatureManagement__PublicRooms`, `FeatureManagement__DiscordActivity`, `FeatureManagement__DuplexAudio` and `FeatureManagement__ScreenShare` default to `true`; explicit `false` disables new work. Public-room and Activity routes return 404, disabled session modes return 409 with `feature_disabled`. Session end, credentials, cleanup, TURN and generic signaling remain available. Public rooms also require `PublicRoom__Enabled=true`. Docker environment changes require `docker compose up -d --force-recreate api`; restarting a container does not reload its environment. `FeatureManagement__DiscordWebSocketMedia` defaults to `false`. + +### Discord WebSocket media rollout + +See [media protocol](docs/protocol/discord-media-v1.md). Existing RTC publishers/viewers, TURN and generic signaling keep their transport. Media uses a separate `media-relay` process that forwards already encoded H.264/Opus with bounded queues, without transcoding. This service is single-instance; do not load-balance one session across instances. + +Set a random `MediaRelay__ServiceToken` in the API deployment `.env`; Compose passes it only to the API and media relay. Set `MediaRelay__PublicBaseUrl=wss://media.example.com/ws/media` to the public upload endpoint. Proxy that host to `sonicrelay-media:8080`, preserving `/ws/media`, with WebSocket Upgrade headers and timeouts longer than 30 seconds. Do not expose `/api/internal/media-relay/*` through a separate public proxy rule; it requires a distinct service credential. + +New transport is disabled by default. To roll out, set `COMPOSE_PROFILES=media`, `FeatureManagement__DiscordWebSocketMedia=true`, and keep `FeatureManagement__DiscordActivity=true` and `FeatureManagement__ScreenShare=true`. Use the API and media images from the same commit (`IMAGE` and `MEDIA_IMAGE`); CI publishes separate SHA tags and leaves existing API image selection intact. Recreate both containers with `docker compose -f docker-compose.prod.yml --profile media up -d --force-recreate api media-relay`. + +On the publisher process, set `FRAMERELAY_WEBSOCKET_MEDIA_ENABLED=true` and restart FrameRelay. This is a process environment variable, not a desktop `.env` loader. When false or missing, there are no upload requests or extra encoders. H.264 output is reused; RTC AV1 uses an independent H.264 encoder for Activity without changing RTC codec selection. + +Configure Discord Activity URL Mapping `/relay` to the existing API, and `/media` to the public media host. The Activity connects to `/media/ws/media` on its mapped origin; it does not use a direct desktop upload URL. It requires supported VideoDecoder/AudioDecoder and checks the actual H.264 profile and Opus configuration; unavailable codecs produce a desktop-viewer message. The play button resumes audio after a user gesture. + +Disabling the media flag stops new grants and denies lease renewal. Leases expire after 30 seconds and renew every 10 seconds; disconnect/release and grant cleanup free Activity capacity without disconnecting RTC publishers. Live Discord verification must check pixels, audible synchronized audio, late join/keyframe recovery, reconnect, session end and a simultaneous desktop RTC viewer before enabling production rollout. + +Local browser fixture: run `dotnet run --project tests/SonicRelay.MediaRelay.BrowserFixture` and the Activity Vite server on port 5173. Open `http://127.0.0.1:5173/tests/browser-media-fixture.html` and click Run. It encodes generated colors and a tone, forwards via real WebSockets, checks actual decoded canvas pixels and PCM energy, and closes resources. The fixture uses a fake grant API on localhost only; production never installs that project. diff --git a/SonicRelay.sln b/SonicRelay.sln index 537f0ec..a6800d2 100644 --- a/SonicRelay.sln +++ b/SonicRelay.sln @@ -29,6 +29,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SonicRelay.Infrastructure.V EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SonicRelay.VirtualPublisher.Tests", "tests\SonicRelay.VirtualPublisher.Tests\SonicRelay.VirtualPublisher.Tests.csproj", "{7C068177-2166-4549-80D8-04B7BC4DA6BC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SonicRelay.MediaProtocol", "src\SonicRelay.MediaProtocol\SonicRelay.MediaProtocol.csproj", "{00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SonicRelay.MediaProtocol.Tests", "tests\SonicRelay.MediaProtocol.Tests\SonicRelay.MediaProtocol.Tests.csproj", "{312F3367-82E4-49EE-BDDA-9E5634127740}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SonicRelay.MediaRelay", "services\SonicRelay.MediaRelay\SonicRelay.MediaRelay.csproj", "{57A3C7BA-4F0F-4E1F-8291-72097B0410B0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SonicRelay.MediaRelay.Tests", "tests\SonicRelay.MediaRelay.Tests\SonicRelay.MediaRelay.Tests.csproj", "{F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -147,6 +155,54 @@ Global {7C068177-2166-4549-80D8-04B7BC4DA6BC}.Release|x64.Build.0 = Release|Any CPU {7C068177-2166-4549-80D8-04B7BC4DA6BC}.Release|x86.ActiveCfg = Release|Any CPU {7C068177-2166-4549-80D8-04B7BC4DA6BC}.Release|x86.Build.0 = Release|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Debug|x64.ActiveCfg = Debug|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Debug|x64.Build.0 = Debug|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Debug|x86.ActiveCfg = Debug|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Debug|x86.Build.0 = Debug|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Release|Any CPU.Build.0 = Release|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Release|x64.ActiveCfg = Release|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Release|x64.Build.0 = Release|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Release|x86.ActiveCfg = Release|Any CPU + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D}.Release|x86.Build.0 = Release|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Debug|Any CPU.Build.0 = Debug|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Debug|x64.ActiveCfg = Debug|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Debug|x64.Build.0 = Debug|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Debug|x86.ActiveCfg = Debug|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Debug|x86.Build.0 = Debug|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Release|Any CPU.ActiveCfg = Release|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Release|Any CPU.Build.0 = Release|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Release|x64.ActiveCfg = Release|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Release|x64.Build.0 = Release|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Release|x86.ActiveCfg = Release|Any CPU + {312F3367-82E4-49EE-BDDA-9E5634127740}.Release|x86.Build.0 = Release|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Debug|x64.ActiveCfg = Debug|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Debug|x64.Build.0 = Debug|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Debug|x86.ActiveCfg = Debug|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Debug|x86.Build.0 = Debug|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Release|Any CPU.Build.0 = Release|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Release|x64.ActiveCfg = Release|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Release|x64.Build.0 = Release|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Release|x86.ActiveCfg = Release|Any CPU + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0}.Release|x86.Build.0 = Release|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Debug|x64.ActiveCfg = Debug|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Debug|x64.Build.0 = Debug|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Debug|x86.ActiveCfg = Debug|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Debug|x86.Build.0 = Debug|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Release|Any CPU.Build.0 = Release|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Release|x64.ActiveCfg = Release|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Release|x64.Build.0 = Release|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Release|x86.ActiveCfg = Release|Any CPU + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -157,5 +213,9 @@ Global {777CEF1C-C33D-466B-8910-41F80DF6BE38} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {A3D8C7F8-9C8A-4789-B471-B4803B149E32} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7C068177-2166-4549-80D8-04B7BC4DA6BC} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {00F83A1F-4FB8-427F-9BAD-2C6F5F1C492D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {312F3367-82E4-49EE-BDDA-9E5634127740} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {57A3C7BA-4F0F-4E1F-8291-72097B0410B0} = {5854FF58-B8C5-05C6-5BF2-ACE42193F22B} + {F870A3A0-7F27-47D1-9AC6-6C42BEBBAB7B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/deploy/deploy.sh b/deploy/deploy.sh index d3468ee..dc1b20d 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -71,9 +71,10 @@ run_migrations() { } export IMAGE +export MEDIA_IMAGE="${MEDIA_IMAGE:-}" echo "Pulling image: $IMAGE" -docker compose -f "$COMPOSE_FILE" pull api +docker compose -f "$COMPOSE_FILE" pull run_migrations diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 797ee16..5162781 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -6,6 +6,7 @@ services: env_file: - .env environment: + FeatureManagement__DiscordWebSocketMedia: "${FeatureManagement__DiscordWebSocketMedia:-false}" PUBLICROOM__ENABLED: "${PUBLICROOM_ENABLED:-false}" PUBLICROOM__TRACKSPATH: "/app/tracks" PUBLICROOM__MAXVIEWERS: "${PUBLICROOM_MAXVIEWERS:-20}" @@ -16,6 +17,22 @@ services: networks: - infra_network + media-relay: + profiles: ["media"] + image: ${MEDIA_IMAGE:-ghcr.io/vitorhugo-dotnet/sonicrelay-media:latest} + container_name: sonicrelay-media + restart: unless-stopped + mem_limit: 512m + environment: + ASPNETCORE_URLS: http://+:8080 + MediaRelay__ApiBaseUrl: http://api:8080/ + MediaRelay__ServiceToken: ${MediaRelay__ServiceToken:-} + MediaRelay__MaxUploads: ${MediaRelay__MaxUploads:-64} + MediaRelay__MaxViewers: ${MediaRelay__MaxViewers:-256} + expose: ["8080"] + depends_on: ["api"] + networks: ["infra_network"] + networks: infra_network: external: true diff --git a/docs/protocol/discord-media-v1-vectors.json b/docs/protocol/discord-media-v1-vectors.json new file mode 100644 index 0000000..5769c2a --- /dev/null +++ b/docs/protocol/discord-media-v1-vectors.json @@ -0,0 +1,61 @@ +[ + { + "name": "config", + "hex": "46524d31010100000100000000000000850000000000000040420f00000000001b410000000000007b22766964656f223a7b22636f646563223a22617663312e343265303166222c227769647468223a3634302c22686569676874223a3336302c22666f726d6174223a22616e6e657862227d2c22617564696f223a7b22636f646563223a226f707573222c2273616d706c6552617465223a34383030302c226368616e6e656c73223a327d7d", + "type": 1, + "timestampUs": 1000000 + }, + { + "name": "video", + "hex": "46524d310102010001000000010000000e0000000000000040420f00000000001b41000000000000000000016742e01f000000016588", + "type": 2, + "timestampUs": 1000000 + }, + { + "name": "audio", + "hex": "46524d31010300000100000002000000030000000000000040420f00000000001b41000000000000f8fffe", + "type": 3, + "timestampUs": 1000000 + }, + { + "name": "keyframe-request", + "hex": "46524d31010400000100000003000000000000000000000040420f00000000001b41000000000000", + "type": 4, + "timestampUs": 1000000 + }, + { + "name": "bad-version", + "hex": "46524d310202010001000000010000000e0000000000000040420f00000000001b41000000000000000000016742e01f000000016588", + "invalid": true + }, + { + "name": "bad-type", + "hex": "46524d310109010001000000010000000e0000000000000040420f00000000001b41000000000000000000016742e01f000000016588", + "invalid": true + }, + { + "name": "bad-flags", + "hex": "46524d310102020001000000010000000e0000000000000040420f00000000001b41000000000000000000016742e01f000000016588", + "invalid": true + }, + { + "name": "zero-generation", + "hex": "46524d310102010000000000010000000e0000000000000040420f00000000001b41000000000000000000016742e01f000000016588", + "invalid": true + }, + { + "name": "reserved", + "hex": "46524d310102010001000000010000000e0000000100000040420f00000000001b41000000000000000000016742e01f000000016588", + "invalid": true + }, + { + "name": "bad-length", + "hex": "46524d31010201000100000001000000630000000000000040420f00000000001b41000000000000000000016742e01f000000016588", + "invalid": true + }, + { + "name": "truncated", + "hex": "46524d31", + "invalid": true + } +] diff --git a/docs/protocol/discord-media-v1.md b/docs/protocol/discord-media-v1.md new file mode 100644 index 0000000..798b295 --- /dev/null +++ b/docs/protocol/discord-media-v1.md @@ -0,0 +1,9 @@ +# Discord media v1 + +WebSocket subprotocol: `framerelay-media-v1`. First complete text message is `{grant}`; every subsequent message is binary. Credentials never appear in URLs. The relay validates grants with RelayControl and renews 30-second leases every 10 seconds. + +Header: 40 bytes, little endian. FRM1 at 0, version 1 at 4, type at 5 (1 configuration, 2 video, 3 audio, 4 keyframe request), flags u16 at 6 (bit 0 video keyframe only), generation u32 at 8, sequence u32 at 12, payload length u32 at 16, reserved zero u32 at 20, timestamp i64 microseconds at 24, duration i64 microseconds at 32. Whole message limit 8 MiB. Reject zero generation, invalid types/flags, negative or JavaScript-unsafe times, trailing or missing bytes. + +Configuration payload is UTF-8 JSON `{video:{codec,width,height,format:"annexb"},audio:{codec:"opus",sampleRate:48000,channels:2}|null}`. H.264 codec string comes from SPS profile/compatibility/level. Video contains complete Annex-B access units, parameter sets on recovery keyframes. Audio contains complete Opus packets. Control has empty payload. No base64 media. + +Each generation starts with configuration and a fresh keyframe. Sequences increase strictly; gaps, backlog, reconnect and codec/size changes require recovery. Queues bound both 64 messages and 16 MiB. A viewer may request one keyframe per second. Supported browser codecs do not imply Discord support; live validation is required before rollout. diff --git a/infra/.env.example b/infra/.env.example index b0126fd..a83a43c 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -36,3 +36,16 @@ PGADMIN_PASSWORD=admin RelayLaunch__DiscordClientId= RelayLaunch__DiscordClientSecret= RelayLaunch__DiscordBotToken= + +# Optional feature switches; absent flags preserve current behavior. +FeatureManagement__PublicRooms=true +FeatureManagement__DiscordActivity=true +FeatureManagement__DuplexAudio=true +FeatureManagement__ScreenShare=true +FeatureManagement__DiscordWebSocketMedia=false +# New opt-in transport. Use a separate random service secret shared only by API and forwarder. +MediaRelay__ServiceToken= +MediaRelay__PublicBaseUrl=wss://media.example.com/ws/media +MediaRelay__MaxUploads=64 +MediaRelay__MaxViewers=256 +# Enable the dedicated container during rollout: COMPOSE_PROFILES=media (deploy stack). diff --git a/infra/.env.prod.example b/infra/.env.prod.example index a7564bd..4b27f5a 100644 --- a/infra/.env.prod.example +++ b/infra/.env.prod.example @@ -57,3 +57,16 @@ TURN_EXTERNAL_IP=203.0.113.10 TURN_MIN_PORT=49160 TURN_MAX_PORT=49200 COTURN_VERSION=latest + +# Optional feature switches; absent flags preserve current behavior. +FeatureManagement__PublicRooms=true +FeatureManagement__DiscordActivity=true +FeatureManagement__DuplexAudio=true +FeatureManagement__ScreenShare=true +FeatureManagement__DiscordWebSocketMedia=false +# New opt-in transport. Use a separate random service secret shared only by API and forwarder. +MediaRelay__ServiceToken= +MediaRelay__PublicBaseUrl=wss://media.example.com/ws/media +MediaRelay__MaxUploads=64 +MediaRelay__MaxViewers=256 +# Enable the dedicated container during rollout: COMPOSE_PROFILES=media (deploy stack). diff --git a/infra/compose.prod.yml b/infra/compose.prod.yml index ccbd25a..d4ec0d4 100644 --- a/infra/compose.prod.yml +++ b/infra/compose.prod.yml @@ -11,6 +11,12 @@ services: - "8080" restart: always + media-relay: + profiles: ["prod", "media"] + image: ${MEDIA_IMAGE:-ghcr.io/vitorhugo-dotnet/sonicrelay-media:latest} + build: null + expose: ["8080"] + postgres: profiles: ["prod"] ports: [] diff --git a/infra/compose.yml b/infra/compose.yml index 289ae82..e813480 100644 --- a/infra/compose.yml +++ b/infra/compose.yml @@ -11,6 +11,13 @@ services: ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT:-Production} ConnectionStrings__Postgres: Host=postgres;Port=5432;Database=${POSTGRES_DB:-sonicrelay};Username=${POSTGRES_USER:-sonicrelay};Password=${POSTGRES_PASSWORD:-change-me} Redis__ConnectionString: redis:6379,password=${REDIS_PASSWORD:-change-me},abortConnect=false + FeatureManagement__PublicRooms: ${FeatureManagement__PublicRooms:-true} + FeatureManagement__DiscordActivity: ${FeatureManagement__DiscordActivity:-true} + FeatureManagement__DuplexAudio: ${FeatureManagement__DuplexAudio:-true} + FeatureManagement__ScreenShare: ${FeatureManagement__ScreenShare:-true} + MediaRelay__ServiceToken: ${MediaRelay__ServiceToken:-} + MediaRelay__PublicBaseUrl: ${MediaRelay__PublicBaseUrl:-} + FeatureManagement__DiscordWebSocketMedia: ${FeatureManagement__DiscordWebSocketMedia:-false} Sessions__CodeTtlMinutes: ${SESSION_CODE_TTL_MINUTES:-10} Sessions__CodeHmacKey: ${SESSION_CODE_HMAC_KEY:-change-this-session-code-hmac-key} Sessions__MaxViewersPerSession: ${SESSION_MAX_VIEWERS:-3} @@ -34,6 +41,21 @@ services: - sonicrelay restart: unless-stopped + media-relay: + profiles: ["media"] + build: + context: ../ + dockerfile: MediaRelay.Dockerfile + mem_limit: 512m + environment: + MediaRelay__ApiBaseUrl: http://api:8080/ + MediaRelay__ServiceToken: ${MediaRelay__ServiceToken:-} + MediaRelay__MaxUploads: ${MediaRelay__MaxUploads:-64} + MediaRelay__MaxViewers: ${MediaRelay__MaxViewers:-256} + depends_on: ["api"] + networks: ["sonicrelay"] + restart: unless-stopped + postgres: container_name: sonicrelay-postgres image: postgres:${POSTGRES_VERSION:-17-alpine} diff --git a/services/SonicRelay.Api/Authorization/MediaRelayServiceAuthenticationHandler.cs b/services/SonicRelay.Api/Authorization/MediaRelayServiceAuthenticationHandler.cs new file mode 100644 index 0000000..015a6b3 --- /dev/null +++ b/services/SonicRelay.Api/Authorization/MediaRelayServiceAuthenticationHandler.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace SonicRelay.Api.Authorization; + +public sealed class MediaRelayServiceAuthenticationHandler(IOptionsMonitor options, + ILoggerFactory logger, UrlEncoder encoder, IConfiguration configuration) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "MediaRelayService"; + protected override Task HandleAuthenticateAsync() + { + var secret = configuration["MediaRelay:ServiceToken"]; + var header = Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(secret) || !header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(AuthenticateResult.NoResult()); + if (!CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(secret)), + SHA256.HashData(Encoding.UTF8.GetBytes(header[7..])))) + return Task.FromResult(AuthenticateResult.Fail("Invalid media service credential.")); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal( + new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "media-relay") }, SchemeName)), SchemeName))); + } +} diff --git a/services/SonicRelay.Api/Contracts/MediaRelayContracts.cs b/services/SonicRelay.Api/Contracts/MediaRelayContracts.cs new file mode 100644 index 0000000..f83a3f5 --- /dev/null +++ b/services/SonicRelay.Api/Contracts/MediaRelayContracts.cs @@ -0,0 +1,5 @@ +namespace SonicRelay.Api.Contracts; + +public sealed record MediaAdmission(Guid AdmissionId, Guid SessionId, Guid ParticipantId, string Grant, + DateTimeOffset ExpiresAt, string MediaUrl); +public sealed record MediaLease(Guid LeaseId, Guid SessionId, Guid? ParticipantId, string Role, DateTimeOffset ExpiresAt); diff --git a/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs b/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs index 0bf888d..1e678fd 100644 --- a/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs @@ -1,3 +1,5 @@ +using Microsoft.FeatureManagement; +using SonicRelay.Api.Features; using Microsoft.EntityFrameworkCore; using SonicRelay.Api.Services; using SonicRelay.Application.Abstractions; @@ -10,10 +12,11 @@ namespace SonicRelay.Api.Endpoints; public static class DiscordActivityEndpoints { public sealed record AuthorizeRequest(string Code, string InstanceId); - public sealed record GrantRequest(string Grant); + public sealed record GrantRequest(string Grant, string? Transport = null); public static IEndpointRouteBuilder MapDiscordActivityEndpoints(this IEndpointRouteBuilder app) { - var group = app.MapGroup("/api/discord/activity").RequireRateLimiting("join-session"); + var group = app.MapGroup("/api/discord/activity").RequireRateLimiting("join-session") + .AddEndpointFilter(new FeatureEndpointFilter(RelayFeatures.DiscordActivity)); group.MapPost("/authorize", async (AuthorizeRequest request, IDiscordActivityValidator discord, AppDbContext db, IParticipantAdmissionLock admissionLock, TimeProvider time, CancellationToken ct) => { DiscordActivityIdentity? identity; @@ -78,8 +81,22 @@ public static IEndpointRouteBuilder MapDiscordActivityEndpoints(this IEndpointRo return Results.Ok(new { grant = token, grant.ExpiresAt }); }); group.MapPost("/viewer-grants/redeem", async (GrantRequest request, HttpContext context, AppDbContext db, IDiscordActivityValidator discord, - IParticipantAdmissionLock admissionLock, TurnCredentialService turn, TimeProvider time, CancellationToken ct) => + IParticipantAdmissionLock admissionLock, TurnCredentialService turn, TimeProvider time, IVariantFeatureManager features, MediaRelayGrantService media, CancellationToken ct) => { + if (request.Transport is not null) + { + if (request.Transport != "websocket") return Results.BadRequest(new { code = "invalid_transport" }); + if (!await features.IsEnabledAsync(RelayFeatures.DiscordWebSocketMedia)) return Results.NotFound(); + var bearer = context.Request.Headers.Authorization.ToString(); + try + { + var result = await media.IssueViewAsync(request.Grant, + bearer.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? bearer[7..] : "", ct); + return result is null ? Results.Unauthorized() : Results.Ok(result); + } + catch (MediaViewerLimitException) { return Results.Conflict(new { code = "viewer_limit" }); } + } + if (!await features.IsEnabledAsync(RelayFeatures.ScreenShare)) return RelayFeatures.Disabled(RelayFeatures.ScreenShare); var now = time.GetUtcNow(); var grant = await RelayCapability.FindAsync(db, request.Grant, "grant", now, ct); if (grant?.SessionId is not { } sessionId) return Results.Unauthorized(); @@ -110,6 +127,14 @@ public static IEndpointRouteBuilder MapDiscordActivityEndpoints(this IEndpointRo var ice = await turn.BuildAsync(device.Id.ToString(), ct); return Results.Ok(new { sessionId, participantId = participant.Id, signalingToken = token, signal.ExpiresAt, ice.IceServers }); }); + group.MapPost("/media-admissions/{admissionId:guid}/release", async (Guid admissionId, HttpContext context, + AppDbContext db, MediaRelayGrantService media, TimeProvider time, CancellationToken ct) => + { + var bearer = context.Request.Headers.Authorization.ToString(); + var identity = await RelayCapability.FindAsync(db, bearer.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? bearer[7..] : "", "identity", time.GetUtcNow(), ct); + if (identity is null) return Results.Unauthorized(); + return await media.ReleaseAdmissionAsync(admissionId, identity, ct) ? Results.NoContent() : Results.NotFound(); + }); return app; } private static Task LiveAsync(AppDbContext db, Guid? id, CancellationToken ct) => db.StreamSessions.AnyAsync(x => x.Id == id && x.Mode == SessionModes.ScreenShare && x.Status != SessionStatuses.Ended && x.Status != SessionStatuses.Expired, ct); diff --git a/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs b/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs index 9be7835..399f2e7 100644 --- a/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs @@ -1,3 +1,4 @@ +using SonicRelay.Api.Features; using System.Security.Claims; using System.Security.Cryptography; using System.Text; @@ -41,7 +42,7 @@ public static IEndpointRouteBuilder MapDiscordActivityLaunchIntentEndpoints(this db.LaunchCapabilities.Add(intent); await db.SaveChangesAsync(ct); return Results.Ok(new { intent.Id, intent.ExpiresAt }); - }); + }).AddEndpointFilter(new FeatureEndpointFilter(RelayFeatures.DiscordActivity)); bot.MapPost("/{intentId:guid}/activity", async (Guid intentId, ReadyActivityRequest request, AppDbContext db, TimeProvider time, CancellationToken ct) => { @@ -61,7 +62,7 @@ public static IEndpointRouteBuilder MapDiscordActivityLaunchIntentEndpoints(this db.LaunchCapabilities.Add(intent); await db.SaveChangesAsync(ct); return Results.Ok(new { intent.Id, intent.ExpiresAt }); - }); + }).AddEndpointFilter(new FeatureEndpointFilter(RelayFeatures.DiscordActivity)); bot.MapPost("/{intentId:guid}/watch", async (Guid intentId, ReadyActivityRequest request, LaunchIntentService service, IOptions options, CancellationToken ct) => { diff --git a/services/SonicRelay.Api/Endpoints/MediaRelayEndpoints.cs b/services/SonicRelay.Api/Endpoints/MediaRelayEndpoints.cs new file mode 100644 index 0000000..8c02333 --- /dev/null +++ b/services/SonicRelay.Api/Endpoints/MediaRelayEndpoints.cs @@ -0,0 +1,26 @@ +using System.Security.Claims; +using SonicRelay.Api.Features; +using SonicRelay.Api.Services; + +namespace SonicRelay.Api.Endpoints; + +public static class MediaRelayEndpoints +{ + public sealed record RedeemRequest(string Grant, Guid ConnectionId); + public sealed record LeaseRequest(Guid LeaseId, Guid ConnectionId); + public static IEndpointRouteBuilder MapMediaRelayEndpoints(this IEndpointRouteBuilder app) + { + app.MapPost("/api/sessions/{sessionId:guid}/media-grants", async (Guid sessionId, ClaimsPrincipal user, + MediaRelayGrantService service, CancellationToken ct) => + await service.IssueUploadAsync(sessionId, user, ct) is { } result ? Results.Ok(result) : Results.NotFound()) + .RequireAuthorization("session:create").AddEndpointFilter(new FeatureEndpointFilter(RelayFeatures.DiscordWebSocketMedia)); + var group = app.MapGroup("/api/internal/media-relay").RequireAuthorization("media-relay:service"); + group.MapPost("/redeem", async (RedeemRequest request, MediaRelayGrantService service, CancellationToken ct) => + await service.RedeemAsync(request.Grant, request.ConnectionId, ct) is { } result ? Results.Ok(result) : Results.Unauthorized()); + group.MapPost("/renew", async (LeaseRequest request, MediaRelayGrantService service, CancellationToken ct) => + await service.RenewAsync(request.LeaseId, request.ConnectionId, ct) is { } result ? Results.Ok(result) : Results.Unauthorized()); + group.MapPost("/release", async (LeaseRequest request, MediaRelayGrantService service, CancellationToken ct) => + { await service.ReleaseAsync(request.LeaseId, request.ConnectionId, ct); return Results.NoContent(); }); + return app; + } +} diff --git a/services/SonicRelay.Api/Endpoints/PublicRoomEndpoints.cs b/services/SonicRelay.Api/Endpoints/PublicRoomEndpoints.cs index 7a9f715..227c43d 100644 --- a/services/SonicRelay.Api/Endpoints/PublicRoomEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/PublicRoomEndpoints.cs @@ -1,3 +1,4 @@ +using SonicRelay.Api.Features; using System.Security.Claims; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -14,7 +15,8 @@ public static IEndpointRouteBuilder MapPublicRoomEndpoints(this IEndpointRouteBu { app.MapGet("/api/public-room", GetAsync) .RequireAuthorization("DeviceAuthenticated") - .WithTags("PublicRoom"); + .WithTags("PublicRoom") + .AddEndpointFilter(new FeatureEndpointFilter(RelayFeatures.PublicRooms)); return app; } diff --git a/services/SonicRelay.Api/Endpoints/SessionEndpoints.cs b/services/SonicRelay.Api/Endpoints/SessionEndpoints.cs index bafa255..eb5ddb4 100644 --- a/services/SonicRelay.Api/Endpoints/SessionEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/SessionEndpoints.cs @@ -1,3 +1,5 @@ +using Microsoft.FeatureManagement; +using SonicRelay.Api.Features; using System.Security.Claims; using System.Security.Cryptography; using System.Text; @@ -37,7 +39,7 @@ public static IEndpointRouteBuilder MapSessionEndpoints(this IEndpointRouteBuild private static async Task CreateAsync(CreateSessionRequest request, ClaimsPrincipal principal, AppDbContext db, ISessionCodeStore codeStore, IConfiguration configuration, - ILoggerFactory loggerFactory, SonicRelayMetrics metrics, CancellationToken ct) + ILoggerFactory loggerFactory, SonicRelayMetrics metrics, IVariantFeatureManager features, CancellationToken ct) { var device = await DeviceIdentityEndpoints.RequireDeviceAsync(principal, db, ct); if (device is null) return Results.Unauthorized(); @@ -51,6 +53,11 @@ private static async Task CreateAsync(CreateSessionRequest request, code = "invalid_session_mode" }); + if (mode == SessionModes.Duplex && !await features.IsEnabledAsync(RelayFeatures.DuplexAudio)) + return RelayFeatures.Disabled(RelayFeatures.DuplexAudio); + if (mode == SessionModes.ScreenShare && !await features.IsEnabledAsync(RelayFeatures.ScreenShare)) + return RelayFeatures.Disabled(RelayFeatures.ScreenShare); + var now = DateTimeOffset.UtcNow; var ttl = CodeTtl(configuration); var session = new StreamSession @@ -211,7 +218,7 @@ private static async Task RotateCodeAsync(Guid sessionId, ClaimsPrincip private static async Task JoinAsync(JoinSessionRequest request, ClaimsPrincipal principal, AppDbContext db, ISessionCodeStore codeStore, IConfiguration configuration, IParticipantAdmissionLock admissionLock, - ILoggerFactory loggerFactory, SonicRelayMetrics metrics, CancellationToken ct) + ILoggerFactory loggerFactory, SonicRelayMetrics metrics, IVariantFeatureManager features, CancellationToken ct) { var device = await DeviceIdentityEndpoints.RequireDeviceAsync(principal, db, ct); if (device is null) return Results.Unauthorized(); @@ -239,7 +246,7 @@ private static async Task JoinAsync(JoinSessionRequest request, ClaimsP return InvalidCode(); } - return await AdmitViewerAsync(session, device, db, admissionLock, loggerFactory, metrics, ct); + return await AdmitViewerAsync(session, device, db, admissionLock, loggerFactory, metrics, features, ct); } // Code-free join for a session the caller found through /discoverable. It runs exactly the @@ -248,7 +255,7 @@ private static async Task JoinAsync(JoinSessionRequest request, ClaimsP // this endpoint cannot be used to probe which session ids exist. private static async Task JoinByIdAsync(Guid sessionId, ClaimsPrincipal principal, AppDbContext db, IParticipantAdmissionLock admissionLock, ILoggerFactory loggerFactory, SonicRelayMetrics metrics, - CancellationToken ct) + IVariantFeatureManager features, CancellationToken ct) { var device = await DeviceIdentityEndpoints.RequireDeviceAsync(principal, db, ct); if (device is null) return Results.Unauthorized(); @@ -257,7 +264,7 @@ private static async Task JoinByIdAsync(Guid sessionId, ClaimsPrincipal if (session is null || session.Status is SessionStatuses.Ended or SessionStatuses.Expired) return InvalidCode(); - return await AdmitViewerAsync(session, device, db, admissionLock, loggerFactory, metrics, ct); + return await AdmitViewerAsync(session, device, db, admissionLock, loggerFactory, metrics, features, ct); } // Shared by both join paths (code and session id): everything that happens once a live @@ -265,8 +272,12 @@ private static async Task JoinByIdAsync(Guid sessionId, ClaimsPrincipal // from drifting on pairing, viewer-limit or reconnect semantics. private static async Task AdmitViewerAsync(StreamSession session, DeviceIdentity device, AppDbContext db, IParticipantAdmissionLock admissionLock, ILoggerFactory loggerFactory, - SonicRelayMetrics metrics, CancellationToken ct) + SonicRelayMetrics metrics, IVariantFeatureManager features, CancellationToken ct) { + if (session.Mode == SessionModes.Duplex && !await features.IsEnabledAsync(RelayFeatures.DuplexAudio)) + return RelayFeatures.Disabled(RelayFeatures.DuplexAudio); + if (session.Mode == SessionModes.ScreenShare && !await features.IsEnabledAsync(RelayFeatures.ScreenShare)) + return RelayFeatures.Disabled(RelayFeatures.ScreenShare); // Admission is read-then-insert, so two joins racing each other would otherwise both see // "no participant yet" and both insert one. That is not a hypothetical: a device coming // back from a network loss legitimately fires several joins at once (the automatic diff --git a/services/SonicRelay.Api/Features/FeatureEndpointFilter.cs b/services/SonicRelay.Api/Features/FeatureEndpointFilter.cs new file mode 100644 index 0000000..fc4406f --- /dev/null +++ b/services/SonicRelay.Api/Features/FeatureEndpointFilter.cs @@ -0,0 +1,12 @@ +using Microsoft.FeatureManagement; + +namespace SonicRelay.Api.Features; + +public sealed class FeatureEndpointFilter(string featureName) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var features = context.HttpContext.RequestServices.GetRequiredService(); + return await features.IsEnabledAsync(featureName) ? await next(context) : Results.NotFound(); + } +} diff --git a/services/SonicRelay.Api/Features/RelayFeatures.cs b/services/SonicRelay.Api/Features/RelayFeatures.cs new file mode 100644 index 0000000..f629381 --- /dev/null +++ b/services/SonicRelay.Api/Features/RelayFeatures.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Configuration.Memory; + +namespace SonicRelay.Api.Features; + +public static class RelayFeatures +{ + public const string PublicRooms = "PublicRooms"; + public const string DiscordActivity = "DiscordActivity"; + public const string DuplexAudio = "DuplexAudio"; + public const string ScreenShare = "ScreenShare"; + public const string DiscordWebSocketMedia = "DiscordWebSocketMedia"; + + public static void AddDefaults(ConfigurationManager configuration) + { + configuration.Sources.Insert(0, new MemoryConfigurationSource + { + InitialData = new Dictionary + { + [$"FeatureManagement:{PublicRooms}"] = "true", + [$"FeatureManagement:{DiscordActivity}"] = "true", + [$"FeatureManagement:{DuplexAudio}"] = "true", + [$"FeatureManagement:{ScreenShare}"] = "true", + [$"FeatureManagement:{DiscordWebSocketMedia}"] = "false" + } + }); + } + + public static IResult Disabled(string feature) => Results.Conflict(new + { + error = "The requested feature is disabled.", code = "feature_disabled", feature + }); +} diff --git a/services/SonicRelay.Api/Program.cs b/services/SonicRelay.Api/Program.cs index 8b562d7..391dff4 100644 --- a/services/SonicRelay.Api/Program.cs +++ b/services/SonicRelay.Api/Program.cs @@ -12,10 +12,14 @@ using Prometheus; using SonicRelay.Api.Authorization; using SonicRelay.Api.Endpoints; +using SonicRelay.Api.Features; +using Microsoft.FeatureManagement; using SonicRelay.Api.Services; using SonicRelay.Infrastructure; var builder = WebApplication.CreateBuilder(args); +RelayFeatures.AddDefaults(builder.Configuration); +builder.Services.AddFeatureManagement(); // The signaling receive loop polls session state every second per socket, which // floods the console with EF `SELECT Status, CodeExpiresAt` command logs. Keep @@ -133,6 +137,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -164,6 +169,7 @@ SignalingGrantAuthenticationHandler.SchemeName, _ => { }) .AddScheme( LaunchServiceAuthenticationHandler.SchemeName, _ => { }) + .AddScheme(MediaRelayServiceAuthenticationHandler.SchemeName, _ => { }) .AddScheme("Activity", _ => { }); builder.Services.AddSingleton(); @@ -235,6 +241,7 @@ policy.Requirements.Add(new DeviceScopeRequirement("signaling:connect")); }); + options.AddPolicy("media-relay:service", policy => policy.AddAuthenticationSchemes(MediaRelayServiceAuthenticationHandler.SchemeName).RequireAuthenticatedUser()); options.AddPolicy("launch-intents:bot", policy => { policy.AddAuthenticationSchemes(LaunchServiceAuthenticationHandler.SchemeName); @@ -303,6 +310,7 @@ app.MapLaunchIntentEndpoints(); app.MapDiscordActivityLaunchIntentEndpoints(); app.MapDiscordActivityEndpoints(); +app.MapMediaRelayEndpoints(); app.MapWebRtcEndpoints(); app.MapSettingsEndpoints(); app.MapSignalingGrantEndpoints(); diff --git a/services/SonicRelay.Api/Services/LaunchCapabilityCleanupService.cs b/services/SonicRelay.Api/Services/LaunchCapabilityCleanupService.cs index 6667b00..c00e0eb 100644 --- a/services/SonicRelay.Api/Services/LaunchCapabilityCleanupService.cs +++ b/services/SonicRelay.Api/Services/LaunchCapabilityCleanupService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using SonicRelay.Application.Abstractions; using SonicRelay.Domain.Sessions; using SonicRelay.Infrastructure.Persistence; @@ -15,6 +16,18 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var db = scope.ServiceProvider.GetRequiredService(); var now = time.GetUtcNow(); var expired = await db.LaunchCapabilities.Where(x => x.ExpiresAt <= now).ToListAsync(stoppingToken); + var locks = scope.ServiceProvider.GetRequiredService(); + foreach (var cap in expired.Where(x => x.Kind is "media-view" or "media-upload").ToArray()) + { + if (cap.SessionId is not { } sessionId) continue; + using var gate = await locks.AcquireAsync(sessionId, Guid.Empty, stoppingToken); + await db.Entry(cap).ReloadAsync(stoppingToken); + if (db.Entry(cap).State == EntityState.Detached || cap.ExpiresAt > time.GetUtcNow()) continue; + if (cap.ParticipantId is { } participantId && await db.SessionParticipants.FindAsync(new object[] { participantId }, stoppingToken) is { } viewer) + { viewer.Status = ParticipantStatuses.Disconnected; viewer.LeftAt = now; } + db.LaunchCapabilities.Remove(cap); await db.SaveChangesAsync(stoppingToken); + } + expired = expired.Where(x => x.Kind is not ("media-view" or "media-upload")).ToList(); var ids = expired.Where(x => x.Kind == "signaling" && x.ParticipantId != null).Select(x => x.ParticipantId!.Value).ToArray(); var participants = await db.SessionParticipants.Where(x => ids.Contains(x.Id)).ToListAsync(stoppingToken); foreach (var participant in participants) diff --git a/services/SonicRelay.Api/Services/MediaRelayGrantService.cs b/services/SonicRelay.Api/Services/MediaRelayGrantService.cs new file mode 100644 index 0000000..c164614 --- /dev/null +++ b/services/SonicRelay.Api/Services/MediaRelayGrantService.cs @@ -0,0 +1,165 @@ +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using Microsoft.FeatureManagement; +using SonicRelay.Api.Contracts; +using SonicRelay.Api.Endpoints; +using SonicRelay.Api.Features; +using SonicRelay.Application.Abstractions; +using SonicRelay.Domain.DeviceIdentities; +using SonicRelay.Domain.Sessions; +using SonicRelay.Infrastructure.Persistence; + +namespace SonicRelay.Api.Services; + +public sealed class MediaRelayGrantService(AppDbContext db, IVariantFeatureManager features, TimeProvider time, + IParticipantAdmissionLock locks, IDiscordActivityValidator discord, IConfiguration configuration) +{ + private async Task EnabledAsync(bool viewer = false) => + await features.IsEnabledAsync(RelayFeatures.DiscordWebSocketMedia) + && await features.IsEnabledAsync(RelayFeatures.ScreenShare) + && (!viewer || await features.IsEnabledAsync(RelayFeatures.DiscordActivity)); + + private Task LiveAsync(Guid id, CancellationToken ct) => db.StreamSessions.SingleOrDefaultAsync(x => + x.Id == id && x.Mode == SessionModes.ScreenShare && x.Status != SessionStatuses.Ended && x.Status != SessionStatuses.Expired, ct); + + public async Task IssueUploadAsync(Guid sessionId, ClaimsPrincipal user, CancellationToken ct) + { + if (!await EnabledAsync()) return null; + var device = await DeviceIdentityEndpoints.RequireDeviceAsync(user, db, ct); + if (device is null) return null; + using var gate = await locks.AcquireAsync(sessionId, Guid.Empty, ct); + var session = await LiveAsync(sessionId, ct); + if (session?.SourceDeviceId != device.Id) return null; + var source = await db.SessionParticipants.SingleOrDefaultAsync(x => x.SessionId == sessionId + && x.DeviceId == device.Id && x.Role == ParticipantRoles.Publisher && x.Status == ParticipantStatuses.Connected, ct); + if (source is null) return null; + var token = RelayCapability.NewToken(); var cap = RelayCapability.Create("media-upload", token, time.GetUtcNow(), 30); + cap.SessionId = sessionId; cap.DeviceId = device.Id; + cap.Code = device.CredentialVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); + // No ParticipantId: this capability never owns RTC cleanup. + db.LaunchCapabilities.Add(cap); await db.SaveChangesAsync(ct); + return Admission(cap, token, source.Id); + } + + public async Task IssueViewAsync(string grantToken, string identityToken, CancellationToken ct) + { + if (!await EnabledAsync(true)) return null; + var now = time.GetUtcNow(); + var grant = await RelayCapability.FindAsync(db, grantToken, "grant", now, ct); + var identity = await RelayCapability.FindAsync(db, identityToken, "identity", now, ct); + if (grant?.SessionId is not { } sessionId || identity is null || grant.Code != identity.Id.ToString("N")) return null; + using var gate = await locks.AcquireAsync(sessionId, Guid.Empty, ct); + await db.Entry(grant).ReloadAsync(ct); + if (grant.ConsumedAt != null || grant.ExpiresAt <= time.GetUtcNow() + || !await ActivityPresenceService.IsAuthorizedAsync(db, discord, identity, now, ct)) return null; + var session = await LiveAsync(sessionId, ct); if (session is null) return null; + await using var transaction = db.Database.IsRelational() + ? await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct) : null; + var old = await db.LaunchCapabilities.Where(x => x.Kind == "media-view" && x.SessionId == sessionId + && x.InstanceId == identity.InstanceId && x.UserId == identity.UserId).ToListAsync(ct); + var participantId = old.FirstOrDefault(x => x.ParticipantId != null)?.ParticipantId; + var participant = participantId.HasValue ? await db.SessionParticipants.FindAsync(new object[] { participantId.Value }, ct) : null; + if (participant?.Status != ParticipantStatuses.Connected && await db.SessionParticipants.CountAsync(x => + x.SessionId == sessionId && x.Role == ParticipantRoles.Viewer + && (x.Status == ParticipantStatuses.Connected || x.Status == ParticipantStatuses.Reconnecting), ct) >= session.MaxViewers) + throw new MediaViewerLimitException(); + if (participant is null) + { + var device = new DeviceIdentity { Id = Guid.NewGuid(), Name = "Discord media viewer", DeviceType = "discord_activity", Platform = "web", CreatedAt = now }; + participant = new SessionParticipant { Id = Guid.NewGuid(), DeviceId = device.Id, SessionId = sessionId, JoinedAt = now }; + SessionAudioPolicy.ApplyDefaults(participant, SessionModes.ScreenShare); + db.DeviceIdentities.Add(device); db.SessionParticipants.Add(participant); + } + participant.Status = ParticipantStatuses.Connected; participant.LeftAt = null; + foreach (var previous in old) { previous.ExpiresAt = now; previous.ParticipantId = null; } + var token = RelayCapability.NewToken(); var cap = RelayCapability.Create("media-view", token, now, 30); + cap.SessionId = sessionId; cap.DeviceId = participant.DeviceId; cap.ParticipantId = participant.Id; + cap.InstanceId = identity.InstanceId; cap.UserId = identity.UserId; cap.GuildId = identity.GuildId; cap.ChannelId = identity.ChannelId; + cap.Code = identity.Id.ToString("N"); grant.ConsumedAt = now; + session.Status = SessionStatuses.Active; session.StartedAt ??= now; + db.LaunchCapabilities.Add(cap); + try { await db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException) { return null; } + if (transaction is not null) await transaction.CommitAsync(ct); + return Admission(cap, token, participant.Id); + } + + private MediaAdmission Admission(LaunchCapability cap, string token, Guid participant) => + new(cap.Id, cap.SessionId!.Value, participant, token, cap.ExpiresAt, configuration["MediaRelay:PublicBaseUrl"] ?? "/media/ws/media"); + + public async Task RedeemAsync(string token, Guid connectionId, CancellationToken ct) + { + if (connectionId == Guid.Empty || token is not { Length: 64 }) return null; + var hash = RelayCapability.Hash(token); + var cap = await db.LaunchCapabilities.SingleOrDefaultAsync(x => x.TokenHash == hash + && (x.Kind == "media-upload" || x.Kind == "media-view"), ct); + if (cap?.SessionId is not { } sessionId) return null; + using var gate = await locks.AcquireAsync(sessionId, Guid.Empty, ct); + await db.Entry(cap).ReloadAsync(ct); + if (cap.ConsumedAt != null || !await ValidAsync(cap, ct)) return null; + cap.ConsumedAt = time.GetUtcNow(); cap.MessageId = connectionId.ToString("N"); cap.ExpiresAt = time.GetUtcNow().AddSeconds(30); + try { await db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException) { return null; } + return Lease(cap); + } + + public async Task RenewAsync(Guid leaseId, Guid connectionId, CancellationToken ct) + { + var cap = await db.LaunchCapabilities.FindAsync(new object[] { leaseId }, ct); + if (cap?.SessionId is not { } sessionId) return null; + using var gate = await locks.AcquireAsync(sessionId, Guid.Empty, ct); + await db.Entry(cap).ReloadAsync(ct); + if (cap.ConsumedAt == null || cap.MessageId != connectionId.ToString("N") || !await ValidAsync(cap, ct)) return null; + cap.ExpiresAt = time.GetUtcNow().AddSeconds(30); await db.SaveChangesAsync(ct); return Lease(cap); + } + + private async Task ValidAsync(LaunchCapability cap, CancellationToken ct) + { + var view = cap.Kind == "media-view"; + if ((!view && cap.Kind != "media-upload") || cap.ExpiresAt <= time.GetUtcNow() + || !await EnabledAsync(view) || cap.SessionId is not { } id) return false; + var session = await LiveAsync(id, ct); if (session is null) return false; + var credentialVersion = int.TryParse(cap.Code, out var version) ? version : -1; + if (!view) return session.SourceDeviceId == cap.DeviceId + && await db.DeviceIdentities.AnyAsync(x => x.Id == cap.DeviceId && x.Status == DeviceIdentityStatuses.Active + && x.RevokedAt == null && x.CredentialVersion == credentialVersion, ct) + && await db.SessionParticipants.AnyAsync(x => x.SessionId == id && x.DeviceId == cap.DeviceId + && x.Role == ParticipantRoles.Publisher && x.Status == ParticipantStatuses.Connected, ct); + if (!Guid.TryParseExact(cap.Code, "N", out var identityId)) return false; + var identity = await db.LaunchCapabilities.FindAsync(new object[] { identityId }, ct); + return identity is { Kind: "identity" } && identity.ExpiresAt > time.GetUtcNow() + && cap.ParticipantId.HasValue && await db.SessionParticipants.AnyAsync(x => x.Id == cap.ParticipantId + && x.Status == ParticipantStatuses.Connected, ct) + && await ActivityPresenceService.IsAuthorizedAsync(db, discord, cap, time.GetUtcNow(), ct); + } + + public async Task ReleaseAsync(Guid leaseId, Guid connectionId, CancellationToken ct) + { + var cap = await db.LaunchCapabilities.FindAsync(new object[] { leaseId }, ct); + if (cap?.SessionId is not { } id || cap.Kind is not ("media-view" or "media-upload")) return false; + using var gate = await locks.AcquireAsync(id, Guid.Empty, ct); await db.Entry(cap).ReloadAsync(ct); + if (cap.MessageId != connectionId.ToString("N")) return false; + await ReleaseCoreAsync(cap, ct); return true; + } + + public async Task ReleaseAdmissionAsync(Guid id, LaunchCapability identity, CancellationToken ct) + { + var cap = await db.LaunchCapabilities.FindAsync(new object[] { id }, ct); + if (cap is null) return true; + if (cap?.SessionId is not { } sessionId || cap.Kind != "media-view" || cap.UserId != identity.UserId + || cap.InstanceId != identity.InstanceId || cap.SessionId != identity.SessionId) return false; + using var gate = await locks.AcquireAsync(sessionId, Guid.Empty, ct); await db.Entry(cap).ReloadAsync(ct); + await ReleaseCoreAsync(cap, ct); return true; + } + + private async Task ReleaseCoreAsync(LaunchCapability cap, CancellationToken ct) + { + cap.ExpiresAt = time.GetUtcNow(); + if (cap.ParticipantId is { } id && await db.SessionParticipants.FindAsync(new object[] { id }, ct) is { } participant) + { participant.Status = ParticipantStatuses.Disconnected; participant.LeftAt = time.GetUtcNow(); } + cap.ParticipantId = null; await db.SaveChangesAsync(ct); + } + + private static MediaLease Lease(LaunchCapability cap) => new(cap.Id, cap.SessionId!.Value, cap.ParticipantId, + cap.Kind == "media-upload" ? "upload" : "view", cap.ExpiresAt); +} + +public sealed class MediaViewerLimitException : Exception; diff --git a/services/SonicRelay.Api/Services/PublicRoomPublisherService.cs b/services/SonicRelay.Api/Services/PublicRoomPublisherService.cs index 1f6371a..bbc0fa3 100644 --- a/services/SonicRelay.Api/Services/PublicRoomPublisherService.cs +++ b/services/SonicRelay.Api/Services/PublicRoomPublisherService.cs @@ -1,3 +1,5 @@ +using Microsoft.FeatureManagement; +using SonicRelay.Api.Features; using System.Collections.Concurrent; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; @@ -34,6 +36,7 @@ public sealed class PublicRoomPublisherService( DeviceCredentialService credentials, IServer server, TimeProvider time, + IVariantFeatureManager features, ILogger logger) : BackgroundService { private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(5); @@ -49,7 +52,7 @@ public sealed class PublicRoomPublisherService( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - if (!options.Value.Enabled) + if (!options.Value.Enabled || !await features.IsEnabledAsync(RelayFeatures.PublicRooms)) { logger.LogInformation("Public radio room is disabled; PublicRoomPublisherService is a no-op"); return; @@ -59,6 +62,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { + if (!await features.IsEnabledAsync(RelayFeatures.PublicRooms)) return; await RunOnceAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) diff --git a/services/SonicRelay.Api/SonicRelay.Api.csproj b/services/SonicRelay.Api/SonicRelay.Api.csproj index 6bba5e4..46c03e4 100644 --- a/services/SonicRelay.Api/SonicRelay.Api.csproj +++ b/services/SonicRelay.Api/SonicRelay.Api.csproj @@ -12,6 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/services/SonicRelay.MediaRelay/MediaConnectionQueue.cs b/services/SonicRelay.MediaRelay/MediaConnectionQueue.cs new file mode 100644 index 0000000..f31d550 --- /dev/null +++ b/services/SonicRelay.MediaRelay/MediaConnectionQueue.cs @@ -0,0 +1,37 @@ +using System.Threading.Channels; +using SonicRelay.MediaProtocol; + +namespace SonicRelay.MediaRelay; + +public sealed class MediaConnectionQueue +{ + private readonly Channel channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + private readonly object gate = new(); + private int count, bytes; + public bool TryEnqueue(MediaMessage message) + { + lock (gate) + { + var size = MediaWireCodec.HeaderSize + message.Payload.Length; + if (count >= 64 || size + bytes > 16 * 1024 * 1024) return false; + if (!channel.Writer.TryWrite(message)) return false; + count++; bytes += size; return true; + } + } + public async ValueTask ReadAsync(CancellationToken ct) + { + while (await channel.Reader.WaitToReadAsync(ct)) + { + lock (gate) + { + if (!channel.Reader.TryRead(out var message)) continue; + count--; bytes -= MediaWireCodec.HeaderSize + message.Payload.Length; return message; + } + } + throw new OperationCanceledException(ct); + } + public void Clear() + { + lock (gate) { while (channel.Reader.TryRead(out _)) { } count = bytes = 0; } + } +} diff --git a/services/SonicRelay.MediaRelay/MediaRelayRegistry.cs b/services/SonicRelay.MediaRelay/MediaRelayRegistry.cs new file mode 100644 index 0000000..40f478b --- /dev/null +++ b/services/SonicRelay.MediaRelay/MediaRelayRegistry.cs @@ -0,0 +1,171 @@ +using System.Net.WebSockets; +using System.Text.Json; +using SonicRelay.MediaProtocol; + +namespace SonicRelay.MediaRelay; + +public sealed class MediaRelayRegistry(IConfiguration configuration) +{ + private sealed class Connection(Guid id, WebSocket socket) + { + public readonly Guid Id = id; + public readonly WebSocket Socket = socket; + public readonly MediaConnectionQueue Queue = new(); + public bool AwaitKey = true; + public DateTimeOffset RecoveryWindow; + public int RecoveryFailures; + } + private sealed class Session + { + public Connection? Upload; + public readonly Dictionary Viewers = new(); + public MediaMessage? Config; + public uint Generation, Sequence; + public bool AwaitKey = true; + public DateTimeOffset LastRequest; + } + private readonly object gate = new(); + private readonly Dictionary sessions = new(); + private int uploads, viewers; + + public async Task AttachAsync(MediaLease lease, Guid connectionId, WebSocket socket, RelayControlLeaseClient api, CancellationToken ct) + { + var connection = new Connection(connectionId, socket); Session session; + lock (gate) + { + if (!sessions.TryGetValue(lease.SessionId, out session!)) sessions[lease.SessionId] = session = new(); + if (lease.Role == "upload") + { + if (session.Upload is not null || uploads >= configuration.GetValue("MediaRelay:MaxUploads", 64)) throw new InvalidOperationException("Upload limit."); + session.Upload = connection; session.Config = null; session.Generation = 0; session.AwaitKey = true; uploads++; + foreach (var viewer in session.Viewers.Values) { viewer.AwaitKey = true; viewer.Queue.Clear(); } + } + else + { + if (viewers >= configuration.GetValue("MediaRelay:MaxViewers", 256)) throw new InvalidOperationException("Viewer limit."); + session.Viewers.Add(connectionId, connection); viewers++; + if (session.Config is { } config) connection.Queue.TryEnqueue(config); + RequestKey(session); + } + } + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct); + try + { + var tasks = new[] { ReceiveLoopAsync(session, connection, lease.Role, lifetime.Token), SendLoopAsync(connection, lifetime.Token), LeaseLoopAsync(lease, connectionId, api, lifetime.Token) }; + await Task.WhenAny(tasks); lifetime.Cancel(); socket.Abort(); + try { await Task.WhenAll(tasks); } catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or InvalidDataException or HttpRequestException or JsonException) { } + } + finally + { + lifetime.Cancel(); + lock (gate) + { + if (lease.Role == "upload" && session.Upload == connection) + { + session.Upload = null; session.Config = null; uploads--; + foreach (var viewer in session.Viewers.Values) { viewer.AwaitKey = true; viewer.Queue.Clear(); } + } + else if (session.Viewers.Remove(connectionId)) viewers--; + if (session.Upload is null && session.Viewers.Count == 0) sessions.Remove(lease.SessionId); + } + } + } + + private static async Task LeaseLoopAsync(MediaLease initial, Guid id, RelayControlLeaseClient api, CancellationToken ct) + { + var lease = initial; + while (true) + { + var remaining = lease.ExpiresAt - DateTimeOffset.UtcNow; + if (remaining <= TimeSpan.Zero) return; + await Task.Delay(remaining < TimeSpan.FromSeconds(10) ? remaining : TimeSpan.FromSeconds(10), ct); + if (lease.ExpiresAt <= DateTimeOffset.UtcNow) return; + using var renewal = CancellationTokenSource.CreateLinkedTokenSource(ct); + renewal.CancelAfter(lease.ExpiresAt - DateTimeOffset.UtcNow); + var next = await api.RenewAsync(lease.LeaseId, id, renewal.Token); + if (next is null || next.LeaseId != lease.LeaseId || next.SessionId != lease.SessionId || next.Role != lease.Role) return; + lease = next; + } + } + + private static async Task SendLoopAsync(Connection connection, CancellationToken ct) + { + while (true) + { + var message = await connection.Queue.ReadAsync(ct); + using var send = CancellationTokenSource.CreateLinkedTokenSource(ct); send.CancelAfter(TimeSpan.FromSeconds(5)); + await connection.Socket.SendAsync(MediaWireCodec.Encode(message).AsMemory(), WebSocketMessageType.Binary, true, send.Token); + } + } + + private async Task ReceiveLoopAsync(Session session, Connection connection, string role, CancellationToken ct) + { + while (await MediaSocketEndpoint.ReceiveAsync(connection.Socket, WebSocketMessageType.Binary, MediaWireCodec.MaxMessageSize, ct) is { } bytes) + { + var message = MediaWireCodec.Decode(bytes); + lock (gate) + { + if (role == "view") + { + if (message.Type != 4) throw new InvalidDataException("Viewer can only request keyframes."); + connection.AwaitKey = true; connection.Queue.Clear(); + if (session.Config is { } config) connection.Queue.TryEnqueue(config); + RequestKey(session); continue; + } + if (message.Type == 4) throw new InvalidDataException("Invalid upload control."); + if (message.Type == 1) + { + if (message.Generation <= session.Generation || message.Sequence != 0) throw new InvalidDataException("Invalid configuration generation."); + ValidateConfig(message.Payload); + session.Config = message; session.Generation = message.Generation; session.Sequence = 0; session.AwaitKey = true; + foreach (var viewer in session.Viewers.Values) { viewer.Queue.Clear(); viewer.AwaitKey = true; viewer.Queue.TryEnqueue(message); } + RequestKey(session); continue; + } + if (session.Config is null || message.Generation != session.Generation || message.Sequence <= session.Sequence) + throw new InvalidDataException("Invalid media generation or sequence."); + if (message.Sequence != session.Sequence + 1) + { session.AwaitKey = true; foreach (var viewer in session.Viewers.Values) viewer.AwaitKey = true; RequestKey(session); } + session.Sequence = message.Sequence; + var key = message.Type == 2 && message.Flags == 1; + if (session.AwaitKey && !key) continue; + if (key) session.AwaitKey = false; + foreach (var viewer in session.Viewers.Values) + { + if (viewer.AwaitKey && !key) continue; + if (key) viewer.AwaitKey = false; + if (!viewer.Queue.TryEnqueue(message)) + { + var now = DateTimeOffset.UtcNow; + if (now - viewer.RecoveryWindow > TimeSpan.FromSeconds(10)) + { viewer.RecoveryWindow = now; viewer.RecoveryFailures = 0; } + if (++viewer.RecoveryFailures >= 3) + { viewer.Socket.Abort(); continue; } + viewer.Queue.Clear(); viewer.AwaitKey = true; + viewer.Queue.TryEnqueue(session.Config.Value); RequestKey(session); + } + } + } + } + } + + private static void ValidateConfig(ReadOnlyMemory bytes) + { + using var json = JsonDocument.Parse(bytes); var video = json.RootElement.GetProperty("video"); + var codec = video.GetProperty("codec").GetString(); + if (codec is null || !System.Text.RegularExpressions.Regex.IsMatch(codec, "^avc1\\.[a-fA-F0-9]{6}$") + || video.GetProperty("format").GetString() != "annexb" + || video.GetProperty("width").GetInt32() is < 1 or > 8192 || video.GetProperty("height").GetInt32() is < 1 or > 8192) + throw new InvalidDataException("Invalid video configuration."); + var audio = json.RootElement.GetProperty("audio"); + if (audio.ValueKind != JsonValueKind.Null && (audio.GetProperty("codec").GetString() != "opus" + || audio.GetProperty("sampleRate").GetInt32() != 48000 || audio.GetProperty("channels").GetInt32() is < 1 or > 2)) + throw new InvalidDataException("Invalid audio configuration."); + } + + private static void RequestKey(Session session) + { + if (session.Upload is null || DateTimeOffset.UtcNow - session.LastRequest < TimeSpan.FromSeconds(1)) return; + session.LastRequest = DateTimeOffset.UtcNow; + if (!session.Upload.Queue.TryEnqueue(new MediaMessage(4, 0, Math.Max(1, session.Generation), 0, 0, 0, ReadOnlyMemory.Empty))) session.Upload.Socket.Abort(); + } +} diff --git a/services/SonicRelay.MediaRelay/MediaSocketEndpoint.cs b/services/SonicRelay.MediaRelay/MediaSocketEndpoint.cs new file mode 100644 index 0000000..0d2891f --- /dev/null +++ b/services/SonicRelay.MediaRelay/MediaSocketEndpoint.cs @@ -0,0 +1,51 @@ +using System.Net.WebSockets; +using System.Text.Json; + +namespace SonicRelay.MediaRelay; + +public static class MediaSocketEndpoint +{ + public static async Task ReceiveAsync(WebSocket socket, WebSocketMessageType expected, int limit, CancellationToken ct) + { + using var data = new MemoryStream(); var chunk = new byte[16384]; + while (true) + { + var received = await socket.ReceiveAsync(chunk.AsMemory(), ct); + if (received.MessageType == WebSocketMessageType.Close) return null; + if (received.MessageType != expected || received.Count > limit - data.Length) throw new InvalidDataException("Invalid socket message."); + data.Write(chunk, 0, received.Count); + if (received.EndOfMessage) return data.ToArray(); + } + } + public static async Task HandleAsync(HttpContext context, RelayControlLeaseClient api, MediaRelayRegistry registry) + { + if (!context.WebSockets.IsWebSocketRequest || !context.WebSockets.WebSocketRequestedProtocols.Contains("framerelay-media-v1")) + { context.Response.StatusCode = 400; return; } + using var socket = await context.WebSockets.AcceptWebSocketAsync("framerelay-media-v1"); + var connectionId = Guid.NewGuid(); MediaLease? lease = null; + try + { + using var handshake = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); handshake.CancelAfter(TimeSpan.FromSeconds(5)); + var bytes = await ReceiveAsync(socket, WebSocketMessageType.Text, 1024, handshake.Token); + if (bytes is null) return; + using var doc = JsonDocument.Parse(bytes); + var grant = doc.RootElement.GetProperty("grant").GetString(); + if (grant is not { Length: 64 }) return; + lease = await api.RedeemAsync(grant, connectionId, handshake.Token); + if (lease is null || lease.ExpiresAt <= DateTimeOffset.UtcNow || lease.Role is not ("upload" or "view")) return; + await registry.AttachAsync(lease, connectionId, socket, api, context.RequestAborted); + } + catch (Exception ex) when (ex is WebSocketException or OperationCanceledException or InvalidDataException + or JsonException or KeyNotFoundException or HttpRequestException or InvalidOperationException) { } + finally + { + socket.Abort(); + if (lease is not null) + { + using var release = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + try { await api.ReleaseAsync(lease.LeaseId, connectionId, release.Token); } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException) { } + } + } + } +} diff --git a/services/SonicRelay.MediaRelay/Program.cs b/services/SonicRelay.MediaRelay/Program.cs new file mode 100644 index 0000000..aa408e2 --- /dev/null +++ b/services/SonicRelay.MediaRelay/Program.cs @@ -0,0 +1,14 @@ +using SonicRelay.MediaRelay; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(builder.Configuration["MediaRelay:ApiBaseUrl"] ?? "http://api:8080/"); + client.Timeout = TimeSpan.FromSeconds(3); +}); +var app = builder.Build(); +app.UseWebSockets(); +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); +app.MapGet("/ws/media", MediaSocketEndpoint.HandleAsync); +app.Run(); diff --git a/services/SonicRelay.MediaRelay/RelayControlLeaseClient.cs b/services/SonicRelay.MediaRelay/RelayControlLeaseClient.cs new file mode 100644 index 0000000..79ad6ad --- /dev/null +++ b/services/SonicRelay.MediaRelay/RelayControlLeaseClient.cs @@ -0,0 +1,26 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; + +namespace SonicRelay.MediaRelay; + +public sealed record MediaLease(Guid LeaseId, Guid SessionId, Guid? ParticipantId, string Role, DateTimeOffset ExpiresAt); +public sealed class RelayControlLeaseClient(HttpClient http, IConfiguration configuration) +{ + private async Task SendAsync(string path, object body, CancellationToken ct) + { + var secret = configuration["MediaRelay:ServiceToken"]; + if (string.IsNullOrWhiteSpace(secret)) return null; + using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secret); + using var response = await http.SendAsync(request, ct); + return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync(ct) : null; + } + public Task RedeemAsync(string grant, Guid connectionId, CancellationToken ct) => SendAsync("api/internal/media-relay/redeem", new { grant, connectionId }, ct); + public Task RenewAsync(Guid leaseId, Guid connectionId, CancellationToken ct) => SendAsync("api/internal/media-relay/renew", new { leaseId, connectionId }, ct); + public async Task ReleaseAsync(Guid leaseId, Guid connectionId, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "api/internal/media-relay/release") { Content = JsonContent.Create(new { leaseId, connectionId }) }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", configuration["MediaRelay:ServiceToken"]); + using var response = await http.SendAsync(request, ct); + } +} diff --git a/services/SonicRelay.MediaRelay/SonicRelay.MediaRelay.csproj b/services/SonicRelay.MediaRelay/SonicRelay.MediaRelay.csproj new file mode 100644 index 0000000..43f5582 --- /dev/null +++ b/services/SonicRelay.MediaRelay/SonicRelay.MediaRelay.csproj @@ -0,0 +1,4 @@ + + net10.0enableenable + + diff --git a/src/SonicRelay.MediaProtocol/MediaMessage.cs b/src/SonicRelay.MediaProtocol/MediaMessage.cs new file mode 100644 index 0000000..76c4fa0 --- /dev/null +++ b/src/SonicRelay.MediaProtocol/MediaMessage.cs @@ -0,0 +1,4 @@ +namespace SonicRelay.MediaProtocol; + +public readonly record struct MediaMessage(byte Type, ushort Flags, uint Generation, uint Sequence, + long TimestampUs, long DurationUs, ReadOnlyMemory Payload); diff --git a/src/SonicRelay.MediaProtocol/MediaWireCodec.cs b/src/SonicRelay.MediaProtocol/MediaWireCodec.cs new file mode 100644 index 0000000..b3c50cc --- /dev/null +++ b/src/SonicRelay.MediaProtocol/MediaWireCodec.cs @@ -0,0 +1,47 @@ +using System.Buffers.Binary; + +namespace SonicRelay.MediaProtocol; + +public static class MediaWireCodec +{ + public const int HeaderSize = 40; + public const int MaxMessageSize = 8 * 1024 * 1024; + public const long MaxSafeInteger = 9007199254740991; + + public static byte[] Encode(MediaMessage message) + { + Validate(message); + var bytes = new byte[HeaderSize + message.Payload.Length]; + "FRM1"u8.CopyTo(bytes); bytes[4] = 1; bytes[5] = message.Type; + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(6), message.Flags); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8), message.Generation); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12), message.Sequence); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16), (uint)message.Payload.Length); + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(24), message.TimestampUs); + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(32), message.DurationUs); + message.Payload.Span.CopyTo(bytes.AsSpan(HeaderSize)); + return bytes; + } + + public static MediaMessage Decode(ReadOnlyMemory bytes) + { + var s = bytes.Span; + if (s.Length < HeaderSize || s.Length > MaxMessageSize || !s[..4].SequenceEqual("FRM1"u8) + || s[4] != 1 || BinaryPrimitives.ReadUInt32LittleEndian(s[20..]) != 0 + || BinaryPrimitives.ReadUInt32LittleEndian(s[16..]) != s.Length - HeaderSize) + throw new InvalidDataException("Invalid media header."); + var message = new MediaMessage(s[5], BinaryPrimitives.ReadUInt16LittleEndian(s[6..]), + BinaryPrimitives.ReadUInt32LittleEndian(s[8..]), BinaryPrimitives.ReadUInt32LittleEndian(s[12..]), + BinaryPrimitives.ReadInt64LittleEndian(s[24..]), BinaryPrimitives.ReadInt64LittleEndian(s[32..]), bytes[HeaderSize..]); + Validate(message); return message; + } + + private static void Validate(MediaMessage message) + { + if (message.Type is < 1 or > 4 || message.Generation == 0 || message.Flags > 1 + || (message.Flags != 0 && message.Type != 2) + || message.TimestampUs is < 0 or > MaxSafeInteger || message.DurationUs is < 0 or > MaxSafeInteger + || message.Payload.Length > MaxMessageSize - HeaderSize || (message.Type == 4 && message.Payload.Length != 0)) + throw new InvalidDataException("Invalid media message."); + } +} diff --git a/src/SonicRelay.MediaProtocol/SonicRelay.MediaProtocol.csproj b/src/SonicRelay.MediaProtocol/SonicRelay.MediaProtocol.csproj new file mode 100644 index 0000000..02e007f --- /dev/null +++ b/src/SonicRelay.MediaProtocol/SonicRelay.MediaProtocol.csproj @@ -0,0 +1 @@ +net10.0enableenable \ No newline at end of file diff --git a/tests/SonicRelay.Api.IntegrationTests/FeatureFlagConfigurationTests.cs b/tests/SonicRelay.Api.IntegrationTests/FeatureFlagConfigurationTests.cs new file mode 100644 index 0000000..bc2a6b5 --- /dev/null +++ b/tests/SonicRelay.Api.IntegrationTests/FeatureFlagConfigurationTests.cs @@ -0,0 +1,45 @@ +using System.Net; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement; +using Xunit; + +namespace SonicRelay.Api.IntegrationTests; + +public sealed class FeatureFlagConfigurationTests +{ + [Theory] + [InlineData("PublicRooms")] + [InlineData("DiscordActivity")] + [InlineData("DuplexAudio")] + [InlineData("ScreenShare")] + public async Task Missing_flags_are_enabled(string feature) + { + await using var factory = new SonicRelayApiFactory(); + Assert.True(await factory.Services.GetRequiredService().IsEnabledAsync(feature)); + } + + [Theory] + [InlineData("PublicRooms")] + [InlineData("DiscordActivity")] + [InlineData("DuplexAudio")] + [InlineData("ScreenShare")] + public async Task Explicit_false_overrides_defaults(string feature) + { + await using var factory = new SonicRelayApiFactory(new Dictionary + { + [$"FeatureManagement:{feature}"] = "false" + }); + Assert.False(await factory.Services.GetRequiredService().IsEnabledAsync(feature)); + } + + [Fact] + public async Task Authentication_is_not_disabled_by_feature_flags() + { + await using var factory = new SonicRelayApiFactory(new Dictionary + { + ["FeatureManagement:ScreenShare"] = "false" + }); + using var client = factory.CreateClient(); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/sessions/active")).StatusCode); + } +} diff --git a/tests/SonicRelay.Api.IntegrationTests/FeatureFlagEndpointsTests.cs b/tests/SonicRelay.Api.IntegrationTests/FeatureFlagEndpointsTests.cs new file mode 100644 index 0000000..b8140f7 --- /dev/null +++ b/tests/SonicRelay.Api.IntegrationTests/FeatureFlagEndpointsTests.cs @@ -0,0 +1,109 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SonicRelay.Api.Services; +using SonicRelay.Domain.Sessions; +using SonicRelay.Infrastructure.Persistence; +using Xunit; + +namespace SonicRelay.Api.IntegrationTests; + +public sealed class FeatureFlagEndpointsTests +{ + [Fact] + public async Task PublicRooms_false_hides_endpoint_without_seeding() + { + await using var factory = new SonicRelayApiFactory(new Dictionary + { ["FeatureManagement:PublicRooms"] = "false", ["PublicRoom:Enabled"] = "true" }); + using var client = factory.CreateClient(); + await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(client, "windows_desktop", "windows"); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/public-room")).StatusCode); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.False(await db.StreamSessions.AnyAsync(x => x.Id == PublicRoomSeeder.PublicSessionId)); + } + + [Theory] + [InlineData("/api/discord/activity/authorize", false)] + [InlineData("/api/discord/activity/viewer-grants", false)] + [InlineData("/api/discord/activity/viewer-grants/redeem", false)] + [InlineData("/api/launch-intents/activity", true)] + public async Task DiscordActivity_false_hides_activity_routes(string path, bool bot) + { + await using var factory = new SonicRelayApiFactory(new Dictionary + { ["FeatureManagement:DiscordActivity"] = "false" }); + using var client = factory.CreateClient(); + if (bot) client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "integration-test-discord-service-token"); + var response = await client.PostAsJsonAsync(path, new + { code = "TEST", instanceId = "instance", grant = "grant", guildId = "1", channelId = "2", requestedByUserId = "3", ttlSeconds = 60 }); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("DuplexAudio", "duplex")] + [InlineData("ScreenShare", "screen_share")] + public async Task Disabled_mode_rejects_create_while_broadcast_works(string feature, string mode) + { + await using var factory = new SonicRelayApiFactory(new Dictionary + { [$"FeatureManagement:{feature}"] = "false" }); + using var client = factory.CreateClient(); + await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(client, "windows_desktop", "windows"); + var response = await client.PostAsJsonAsync("/api/sessions", new { mode }); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Contains("feature_disabled", await response.Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.Created, (await client.PostAsJsonAsync("/api/sessions", new { mode = "broadcast" })).StatusCode); + using var scope = factory.Services.CreateScope(); + Assert.False(await scope.ServiceProvider.GetRequiredService().StreamSessions.AnyAsync(x => x.Mode == mode)); + } + + [Theory] + [InlineData("ScreenShare", "screen_share")] + [InlineData("DuplexAudio", "duplex")] + public async Task Disabled_mode_blocks_join_and_preserves_session_end(string feature, string mode) + { + await using var factory = new SonicRelayApiFactory(new Dictionary + { [$"FeatureManagement:{feature}"] = "false" }); + using var owner = factory.CreateClient(); + var identity = await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(owner, "windows_desktop", "windows"); + var session = new StreamSession + { Id = Guid.NewGuid(), SourceDeviceId = identity.DeviceId, Mode = mode, CodeExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), CreatedAt = DateTimeOffset.UtcNow }; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.StreamSessions.Add(session); + await db.SaveChangesAsync(); + } + var codeHash = Convert.ToHexString(System.Security.Cryptography.HMACSHA256.HashData( + System.Text.Encoding.UTF8.GetBytes("integration-test-session-code-key"), System.Text.Encoding.ASCII.GetBytes("ABC123"))); + await factory.Services.GetRequiredService() + .StoreAsync(codeHash, session.Id, TimeSpan.FromMinutes(10), CancellationToken.None); + Assert.Equal(HttpStatusCode.Conflict, (await owner.PostAsJsonAsync("/api/sessions/join", new { code = "ABC123" })).StatusCode); + Assert.Equal(HttpStatusCode.Conflict, (await owner.PostAsJsonAsync($"/api/sessions/{session.Id}/join", new { })).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await owner.PostAsJsonAsync($"/api/sessions/{session.Id}/end", new { })).StatusCode); + } + + [Fact] + public async Task Discord_disable_preserves_desktop_session_and_ice_routes() + { + await using var factory = new SonicRelayApiFactory(new Dictionary + { ["FeatureManagement:DiscordActivity"] = "false" }); + using var client = factory.CreateClient(); + await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(client, "windows_desktop", "windows"); + Assert.Equal(HttpStatusCode.Created, (await client.PostAsJsonAsync("/api/sessions", new { mode = "screen_share" })).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/webrtc/ice-servers")).StatusCode); + } + + [Fact] + public async Task PublicRooms_false_starts_no_publisher_work() + { + await using var factory = new SonicRelayApiFactory(new Dictionary { + ["PublicRoom:Enabled"] = "true", ["FeatureManagement:PublicRooms"] = "false" }); + using var client = factory.CreateClient(); + var service = factory.Services.GetRequiredService(); + await service.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(3)); + using var scope = factory.Services.CreateScope(); + Assert.False(await scope.ServiceProvider.GetRequiredService().StreamSessions.AnyAsync()); + } +} diff --git a/tests/SonicRelay.Api.IntegrationTests/MediaRelayGrantTests.cs b/tests/SonicRelay.Api.IntegrationTests/MediaRelayGrantTests.cs new file mode 100644 index 0000000..57087f1 --- /dev/null +++ b/tests/SonicRelay.Api.IntegrationTests/MediaRelayGrantTests.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SonicRelay.Api.Services; +using SonicRelay.Domain.Sessions; +using SonicRelay.Infrastructure.Persistence; +using Xunit; + +namespace SonicRelay.Api.IntegrationTests; + +public sealed class MediaRelayGrantTests +{ + private sealed class Clock : TimeProvider + { + public DateTimeOffset Now = DateTimeOffset.UtcNow; + public override DateTimeOffset GetUtcNow() => Now; + } + [Fact] + public async Task Upload_expiry_and_source_revocation_reject_renewal_without_disconnecting_rtc() + { + var clock = new Clock(); + await using var factory = new SonicRelayApiFactory(new Dictionary { + ["FeatureManagement:DiscordWebSocketMedia"] = "true", ["MediaRelay:ServiceToken"] = "media-test" }) { TimeProviderOverride = clock }; + using var owner = factory.CreateClient(); var device = await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(owner, "windows_desktop", "windows"); + var session = await Json(await owner.PostAsJsonAsync("/api/sessions", new { mode = "screen_share" })); + var id = session.GetProperty("id").GetGuid(); + using var stranger = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(stranger, "windows_desktop", "windows"); + Assert.Equal(HttpStatusCode.NotFound, (await stranger.PostAsync($"/api/sessions/{id}/media-grants", null)).StatusCode); + var normal = await Json(await owner.PostAsJsonAsync("/api/sessions", new { mode = "broadcast" })); + Assert.Equal(HttpStatusCode.NotFound, (await owner.PostAsync($"/api/sessions/{normal.GetProperty("id").GetGuid()}/media-grants", null)).StatusCode); + var grant = await Json(await owner.PostAsync($"/api/sessions/{id}/media-grants", null)); + var connectionId = Guid.NewGuid(); + Assert.Equal(HttpStatusCode.Unauthorized, (await owner.PostAsJsonAsync("/api/internal/media-relay/redeem", new { grant = grant.GetProperty("grant").GetString(), connectionId })).StatusCode); + using var relay = factory.CreateClient(); relay.DefaultRequestHeaders.Authorization = new("Bearer", "media-test"); + var lease = await Json(await relay.PostAsJsonAsync("/api/internal/media-relay/redeem", new { grant = grant.GetProperty("grant").GetString(), connectionId })); + var leaseId = lease.GetProperty("leaseId").GetGuid(); clock.Now = clock.Now.AddSeconds(31); + Assert.Equal(HttpStatusCode.Unauthorized, (await relay.PostAsJsonAsync("/api/internal/media-relay/renew", new { leaseId, connectionId })).StatusCode); + using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(ParticipantStatuses.Connected, (await db.SessionParticipants.SingleAsync(x => x.SessionId == id && x.Role == ParticipantRoles.Publisher)).Status); + var fresh = await Json(await owner.PostAsync($"/api/sessions/{id}/media-grants", null)); + var freshLease = await Json(await relay.PostAsJsonAsync("/api/internal/media-relay/redeem", new { grant = fresh.GetProperty("grant").GetString(), connectionId })); + var identity = await db.DeviceIdentities.SingleAsync(x => x.Id == device.DeviceId); identity.CredentialVersion++; await db.SaveChangesAsync(); + Assert.Equal(HttpStatusCode.Unauthorized, (await relay.PostAsJsonAsync("/api/internal/media-relay/renew", new { leaseId = freshLease.GetProperty("leaseId").GetGuid(), connectionId })).StatusCode); + } + private sealed class Discord : IDiscordActivityValidator + { + public bool Present = true; + public Task AuthorizeAsync(string code, string instanceId, CancellationToken ct) => Task.FromResult(new("3", instanceId, "1", "2")); + public Task IsPresentAsync(DiscordActivityIdentity identity, CancellationToken ct) => Task.FromResult(Present); + } + + [Fact] + public async Task Reconnection_reuses_capacity_and_old_release_cannot_disconnect_new_admission() + { + var discord = new Discord(); + await using var root = new SonicRelayApiFactory(new Dictionary { + ["FeatureManagement:DiscordWebSocketMedia"] = "true", ["MediaRelay:ServiceToken"] = "media-test", ["LaunchIntents:ServiceToken"] = "bot-test" }); + await using var factory = root.WithWebHostBuilder(builder => builder.ConfigureServices(services => { + services.RemoveAll(); services.AddSingleton(discord); })); + using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, "windows_desktop", "windows"); + var session = await Json(await host.PostAsJsonAsync("/api/sessions", new { mode = "screen_share", maxViewers = 1 })); + using var bot = factory.CreateClient(); bot.DefaultRequestHeaders.Authorization = new("Bearer", "bot-test"); + Assert.Equal(HttpStatusCode.OK, (await bot.PostAsJsonAsync("/api/launch-intents/activity", new { code = session.GetProperty("code").GetString(), guildId = "1", channelId = "2", requestedByUserId = "3" })).StatusCode); + using var viewer = factory.CreateClient(); + var identity = await Json(await viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "valid", instanceId = "instance" })); + viewer.DefaultRequestHeaders.Authorization = new("Bearer", identity.GetProperty("accessToken").GetString()); + async Task Admit() + { + var grant = await Json(await viewer.PostAsync("/api/discord/activity/viewer-grants", null)); + return await Json(await viewer.PostAsJsonAsync("/api/discord/activity/viewer-grants/redeem", new { grant = grant.GetProperty("grant").GetString(), transport = "websocket" })); + } + var first = await Admit(); var firstId = first.GetProperty("admissionId").GetGuid(); + Assert.False(first.TryGetProperty("iceServers", out _)); Assert.False(first.TryGetProperty("signalingToken", out _)); + using var relay = factory.CreateClient(); relay.DefaultRequestHeaders.Authorization = new("Bearer", "media-test"); + var connectionId = Guid.NewGuid(); + await Json(await relay.PostAsJsonAsync("/api/internal/media-relay/redeem", new { grant = first.GetProperty("grant").GetString(), connectionId })); + var second = await Admit(); var secondId = second.GetProperty("admissionId").GetGuid(); + Assert.Equal(first.GetProperty("participantId").GetGuid(), second.GetProperty("participantId").GetGuid()); + await relay.PostAsJsonAsync("/api/internal/media-relay/release", new { leaseId = firstId, connectionId }); + Assert.Equal(HttpStatusCode.NoContent, (await viewer.PostAsync($"/api/discord/activity/media-admissions/{firstId}/release", null)).StatusCode); + using (var scope = factory.Services.CreateScope()) { + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(ParticipantStatuses.Connected, (await db.SessionParticipants.SingleAsync(x => x.Id == second.GetProperty("participantId").GetGuid())).Status); + Assert.Equal(1, await db.SessionParticipants.CountAsync(x => x.Role == ParticipantRoles.Viewer && x.Status == ParticipantStatuses.Connected)); + db.LaunchCapabilities.Remove(await db.LaunchCapabilities.SingleAsync(x => x.Id == firstId)); + await db.SaveChangesAsync(); + } + Assert.Equal(HttpStatusCode.NoContent, (await viewer.PostAsync($"/api/discord/activity/media-admissions/{firstId}/release", null)).StatusCode); + var secondConnection = Guid.NewGuid(); await Json(await relay.PostAsJsonAsync("/api/internal/media-relay/redeem", new { grant = second.GetProperty("grant").GetString(), connectionId = secondConnection })); + discord.Present = false; + Assert.Equal(HttpStatusCode.Unauthorized, (await relay.PostAsJsonAsync("/api/internal/media-relay/renew", new { leaseId = secondId, connectionId = secondConnection })).StatusCode); + using var finalScope = factory.Services.CreateScope(); + Assert.Equal(ParticipantStatuses.Disconnected, (await finalScope.ServiceProvider.GetRequiredService().SessionParticipants.SingleAsync(x => x.Id == second.GetProperty("participantId").GetGuid())).Status); + } + private static async Task Json(HttpResponseMessage response) { response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(); } + [Theory] + [InlineData(false)] [InlineData(true)] + public async Task Upload_is_opt_in_single_use_and_never_owns_rtc_participant(bool enabled) + { + await using var factory = new SonicRelayApiFactory(new Dictionary { + ["FeatureManagement:DiscordWebSocketMedia"] = enabled.ToString(), + ["MediaRelay:ServiceToken"] = "media-test", ["MediaRelay:PublicBaseUrl"] = "wss://media.example/ws/media" }); + using var host = factory.CreateClient(); + await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, "windows_desktop", "windows"); + var session = await (await host.PostAsJsonAsync("/api/sessions", new { mode = "screen_share" })).Content.ReadFromJsonAsync(); + var id = session.GetProperty("id").GetGuid(); + var issued = await host.PostAsJsonAsync($"/api/sessions/{id}/media-grants", new { }); + if (!enabled) { Assert.Equal(HttpStatusCode.NotFound, issued.StatusCode); return; } + Assert.Equal(HttpStatusCode.OK, issued.StatusCode); + var admission = await issued.Content.ReadFromJsonAsync(); + using var relay = factory.CreateClient(); relay.DefaultRequestHeaders.Authorization = new("Bearer", "media-test"); + var connectionId = Guid.NewGuid(); var grant = admission.GetProperty("grant").GetString(); + var redeemed = await relay.PostAsJsonAsync("/api/internal/media-relay/redeem", new { grant, connectionId }); + Assert.Equal(HttpStatusCode.OK, redeemed.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await relay.PostAsJsonAsync("/api/internal/media-relay/redeem", new { grant, connectionId })).StatusCode); + var lease = await redeemed.Content.ReadFromJsonAsync(); + var leaseId = lease.GetProperty("leaseId").GetGuid(); + Assert.Equal("upload", lease.GetProperty("role").GetString()); + Assert.Equal(HttpStatusCode.Unauthorized, (await relay.PostAsJsonAsync("/api/internal/media-relay/renew", new { leaseId, connectionId = Guid.NewGuid() })).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await relay.PostAsJsonAsync("/api/internal/media-relay/renew", new { leaseId, connectionId })).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await relay.PostAsJsonAsync("/api/internal/media-relay/release", new { leaseId, connectionId })).StatusCode); + using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + Assert.Null((await db.LaunchCapabilities.SingleAsync(x => x.Id == leaseId)).ParticipantId); + Assert.Equal(ParticipantStatuses.Connected, (await db.SessionParticipants.SingleAsync(x => x.SessionId == id)).Status); + } +} diff --git a/tests/SonicRelay.Api.IntegrationTests/PublicRoomPublisherServiceTests.cs b/tests/SonicRelay.Api.IntegrationTests/PublicRoomPublisherServiceTests.cs index 45f6a82..c624742 100644 --- a/tests/SonicRelay.Api.IntegrationTests/PublicRoomPublisherServiceTests.cs +++ b/tests/SonicRelay.Api.IntegrationTests/PublicRoomPublisherServiceTests.cs @@ -38,11 +38,12 @@ public async Task ExecuteAsync_seeds_the_session_when_enabled_even_with_no_track }); _ = factory.CreateClient(); - // Give the hosted service's startup a moment to run its seeding step. - await Task.Delay(TimeSpan.FromSeconds(1)); - using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + // Wait for the actual startup milestone rather than assuming a loaded runner finishes in one second. + using var startupBudget = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!await db.StreamSessions.AnyAsync(x => x.Id == PublicRoomSeeder.PublicSessionId, startupBudget.Token)) + await Task.Delay(20, startupBudget.Token); Assert.True(await db.StreamSessions.AnyAsync(x => x.Id == PublicRoomSeeder.PublicSessionId)); await factory.DisposeAsync(); Directory.Delete(emptyTracksDir, recursive: true); diff --git a/tests/SonicRelay.MediaProtocol.Tests/MediaWireCodecTests.cs b/tests/SonicRelay.MediaProtocol.Tests/MediaWireCodecTests.cs new file mode 100644 index 0000000..bb844a2 --- /dev/null +++ b/tests/SonicRelay.MediaProtocol.Tests/MediaWireCodecTests.cs @@ -0,0 +1,48 @@ +using SonicRelay.MediaProtocol; + +namespace SonicRelay.MediaProtocol.Tests; + +public sealed class MediaWireCodecTests +{ + [Theory] + [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] + public void Roundtrip_matches_header(int type) + { + var payload = type == 4 ? Array.Empty() : new byte[] { 1, 2, 3 }; + var sample = new MediaMessage((byte)type, (ushort)(type == 2 ? 1 : 0), 1, 3, 1000000, 16667, payload); + var wire = MediaWireCodec.Encode(sample); + Assert.Equal("FRM1", System.Text.Encoding.ASCII.GetString(wire, 0, 4)); + Assert.Equal(payload.Length + 40, wire.Length); + var decoded = MediaWireCodec.Decode(wire); + Assert.Equal(sample.TimestampUs, decoded.TimestampUs); + Assert.Equal(sample.Payload.ToArray(), decoded.Payload.ToArray()); + } + + [Theory] + [InlineData(4, 2)] [InlineData(5, 9)] [InlineData(6, 2)] [InlineData(8, 0)] [InlineData(16, 9)] [InlineData(20, 1)] + public void Rejects_invalid_headers(int offset, byte value) + { + var wire = MediaWireCodec.Encode(new MediaMessage(2, 1, 1, 0, 0, 1, new byte[] { 7 })); + wire[offset] = value; + Assert.Throws(() => MediaWireCodec.Decode(wire)); + } + + [Fact] + public void Rejects_unsafe_time_and_oversized_payload() + { + Assert.Throws(() => MediaWireCodec.Decode(new byte[39])); + Assert.Throws(() => MediaWireCodec.Encode(new MediaMessage(2, 0, 1, 0, long.MaxValue, 0, Array.Empty()))); + Assert.Throws(() => MediaWireCodec.Encode(new MediaMessage(2, 0, 1, 0, 0, 0, new byte[8 * 1024 * 1024]))); + } + [Fact] + public void Shared_vectors_decode_and_reencode_exact_bytes() + { + using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "discord-media-v1-vectors.json"))); + foreach (var vector in json.RootElement.EnumerateArray()) + { + var wire = Convert.FromHexString(vector.GetProperty("hex").GetString()!); + if (vector.TryGetProperty("invalid", out _)) Assert.Throws(() => MediaWireCodec.Decode(wire)); + else Assert.Equal(wire, MediaWireCodec.Encode(MediaWireCodec.Decode(wire))); + } + } +} diff --git a/tests/SonicRelay.MediaProtocol.Tests/SonicRelay.MediaProtocol.Tests.csproj b/tests/SonicRelay.MediaProtocol.Tests/SonicRelay.MediaProtocol.Tests.csproj new file mode 100644 index 0000000..c3883c0 --- /dev/null +++ b/tests/SonicRelay.MediaProtocol.Tests/SonicRelay.MediaProtocol.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/SonicRelay.MediaRelay.BrowserFixture/Program.cs b/tests/SonicRelay.MediaRelay.BrowserFixture/Program.cs new file mode 100644 index 0000000..264a16e --- /dev/null +++ b/tests/SonicRelay.MediaRelay.BrowserFixture/Program.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using SonicRelay.MediaRelay; + +// Local codec fixture only. Production uses RelayControl's authenticated grant endpoints. +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls("http://127.0.0.1:5174"); +builder.Configuration["MediaRelay:ServiceToken"] = "fixture"; +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new RelayControlLeaseClient(new HttpClient(new FixtureApi()) { BaseAddress = new("http://fixture/") }, builder.Configuration)); +var app = builder.Build(); app.UseWebSockets(); app.MapGet("/ws/media", MediaSocketEndpoint.HandleAsync); app.Run(); + +sealed class FixtureApi : HttpMessageHandler +{ + private readonly Guid session = Guid.NewGuid(); + private readonly Dictionary leases = new(); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + using var json = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(ct)); + lock (leases) + { + if (request.RequestUri!.AbsolutePath.EndsWith("redeem")) + { + var grant = json.RootElement.GetProperty("grant").GetString(); + if (grant is not { Length: 64 } || grant[0] is not ('u' or 'v')) return new(HttpStatusCode.Unauthorized); + var lease = new MediaLease(Guid.NewGuid(), session, null, grant[0] == 'u' ? "upload" : "view", DateTimeOffset.UtcNow.AddSeconds(30)); + leases[lease.LeaseId] = lease; return new(HttpStatusCode.OK) { Content = JsonContent.Create(lease) }; + } + var id = json.RootElement.GetProperty("leaseId").GetGuid(); + if (request.RequestUri.AbsolutePath.EndsWith("release")) { leases.Remove(id); return new(HttpStatusCode.NoContent); } + return leases.TryGetValue(id, out var current) ? new(HttpStatusCode.OK) { Content = JsonContent.Create(current with { ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(30) }) } : new(HttpStatusCode.Unauthorized); + } + } +} diff --git a/tests/SonicRelay.MediaRelay.BrowserFixture/SonicRelay.MediaRelay.BrowserFixture.csproj b/tests/SonicRelay.MediaRelay.BrowserFixture/SonicRelay.MediaRelay.BrowserFixture.csproj new file mode 100644 index 0000000..9e1120c --- /dev/null +++ b/tests/SonicRelay.MediaRelay.BrowserFixture/SonicRelay.MediaRelay.BrowserFixture.csproj @@ -0,0 +1 @@ +net10.0enableenable diff --git a/tests/SonicRelay.MediaRelay.Tests/MediaRelayTests.cs b/tests/SonicRelay.MediaRelay.Tests/MediaRelayTests.cs new file mode 100644 index 0000000..7794b2d --- /dev/null +++ b/tests/SonicRelay.MediaRelay.Tests/MediaRelayTests.cs @@ -0,0 +1,149 @@ +using System.Net; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SonicRelay.MediaProtocol; +using SonicRelay.MediaRelay; + +namespace SonicRelay.MediaRelay.Tests; + +public sealed class MediaRelayTests +{ + [Fact] + public async Task Queue_bounds_messages_and_bytes_and_clear_does_not_terminate_reader() + { + var queue = new MediaConnectionQueue(); var sample = new MediaMessage(2, 1, 1, 0, 0, 0, new byte[300000]); + var count = 0; while (queue.TryEnqueue(sample)) count++; + Assert.InRange(count, 1, 63); + using var ct = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + queue.Clear(); var pending = queue.ReadAsync(ct.Token).AsTask(); queue.TryEnqueue(sample); + Assert.Equal(sample.Payload.Length, (await pending).Payload.Length); + queue.Clear(); sample = sample with { Payload = ReadOnlyMemory.Empty }; + for (var i = 0; i < 64; i++) Assert.True(queue.TryEnqueue(sample)); + Assert.False(queue.TryEnqueue(sample)); + } + + private sealed class Api(Guid session) : HttpMessageHandler + { + public double LifetimeSeconds = 30; + public readonly List Released = new(); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var json = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(ct)); + if (request.RequestUri!.AbsolutePath.EndsWith("/release")) + { lock (Released) Released.Add(json.RootElement.GetProperty("connectionId").GetGuid()); return new(HttpStatusCode.NoContent); } + var role = "view"; + if (request.RequestUri.AbsolutePath.EndsWith("/redeem")) + { + var grant = json.RootElement.GetProperty("grant").GetString()!; + if (grant[0] == 'x') return new(HttpStatusCode.Unauthorized); + role = grant[0] == 'u' ? "upload" : "view"; + } + return new(HttpStatusCode.OK) { Content = JsonContent.Create(new MediaLease(Guid.NewGuid(), session, null, role, DateTimeOffset.UtcNow.AddSeconds(LifetimeSeconds))) }; + } + } + private sealed class Host : IAsyncDisposable + { + public readonly CancellationTokenSource Budget = new(TimeSpan.FromSeconds(10)); + public readonly Api Api = new(Guid.NewGuid()); + private readonly WebApplication app; + public Host() + { + var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Configuration.AddInMemoryCollection(new Dictionary { ["MediaRelay:ServiceToken"] = "test" }); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(new RelayControlLeaseClient(new HttpClient(Api) { BaseAddress = new("http://api/") }, builder.Configuration)); + app = builder.Build(); app.UseWebSockets(); app.MapGet("/ws/media", MediaSocketEndpoint.HandleAsync); + } + public async Task Connect(string grant) + { + if (app.Urls.All(x => x.EndsWith(":0"))) await app.StartAsync(Budget.Token); + var socket = new ClientWebSocket(); socket.Options.AddSubProtocol("framerelay-media-v1"); + await socket.ConnectAsync(new Uri(app.Urls.Single().Replace("http:", "ws:") + "/ws/media"), Budget.Token); + await socket.SendAsync(JsonSerializer.SerializeToUtf8Bytes(new { grant = new string(grant[0], 64) }).AsMemory(), WebSocketMessageType.Text, true, Budget.Token); + return socket; + } + public async ValueTask DisposeAsync() { Budget.Cancel(); await app.StopAsync(); await app.DisposeAsync(); Budget.Dispose(); } + } + + [Fact] + public async Task Viewers_receive_identical_samples_and_duplicate_upload_is_rejected() + { + await using var host = new Host(); using var upload = await host.Connect("u"); + var config = new MediaMessage(1, 0, 1, 0, 0, 0, JsonSerializer.SerializeToUtf8Bytes(new { video = new { codec = "avc1.42e01f", width = 640, height = 360, format = "annexb" }, audio = (object?)null })); + await Send(upload, config, host.Budget.Token); + Assert.Equal(4, (await Receive(upload, host.Budget.Token)).Type); + using var first = await host.Connect("v"); using var second = await host.Connect("v"); + Assert.Equal(1, (await Receive(first, host.Budget.Token)).Type); Assert.Equal(1, (await Receive(second, host.Budget.Token)).Type); + var key = new MediaMessage(2, 1, 1, 1, 1000000, 16667, new byte[] { 0, 0, 0, 1, 0x65 }); + await Send(upload, key, host.Budget.Token); + Assert.Equal(MediaWireCodec.Encode(key), MediaWireCodec.Encode(await Receive(first, host.Budget.Token))); + Assert.Equal(MediaWireCodec.Encode(key), MediaWireCodec.Encode(await Receive(second, host.Budget.Token))); + using var duplicate = await host.Connect("u"); + await Assert.ThrowsAnyAsync(() => MediaSocketEndpoint.ReceiveAsync(duplicate, WebSocketMessageType.Binary, 1024, host.Budget.Token)); + var delta = key with { Flags = 0, Sequence = 2 }; await Send(upload, delta, host.Budget.Token); + Assert.Equal(2u, (await Receive(first, host.Budget.Token)).Sequence); + } + [Fact] + public async Task Unknown_grant_cannot_subscribe() + { + await using var host = new Host(); using var socket = await host.Connect("x"); + await Assert.ThrowsAnyAsync(() => MediaSocketEndpoint.ReceiveAsync(socket, WebSocketMessageType.Binary, 1024, host.Budget.Token)); + } + [Fact] + public async Task Lease_expiry_closes_socket() + { + await using var host = new Host(); host.Api.LifetimeSeconds = 0.5; + using var socket = await host.Connect("v"); + await Assert.ThrowsAnyAsync(() => MediaSocketEndpoint.ReceiveAsync(socket, WebSocketMessageType.Binary, 1024, host.Budget.Token)); + } + [Fact] + public async Task Sequence_gap_discards_delta_until_fresh_keyframe_and_new_generation_reconfigures() + { + await using var host = new Host(); using var upload = await host.Connect("u"); + var config = new MediaMessage(1, 0, 1, 0, 0, 0, JsonSerializer.SerializeToUtf8Bytes(new { video = new { codec = "avc1.42e01f", width = 640, height = 360, format = "annexb" }, audio = (object?)null })); + await Send(upload, config, host.Budget.Token); await Receive(upload, host.Budget.Token); + using var viewer = await host.Connect("v"); await Receive(viewer, host.Budget.Token); + var key = new MediaMessage(2, 1, 1, 1, 0, 16667, new byte[] { 0, 0, 0, 1, 0x65 }); + await Send(upload, key, host.Budget.Token); await Receive(viewer, host.Budget.Token); + await Send(upload, key with { Sequence = 3, Flags = 0 }, host.Budget.Token); + await Send(upload, key with { Sequence = 4 }, host.Budget.Token); + Assert.Equal(4u, (await Receive(viewer, host.Budget.Token)).Sequence); + await Send(upload, config with { Generation = 2 }, host.Budget.Token); + Assert.Equal(2u, (await Receive(viewer, host.Budget.Token)).Generation); + await Send(upload, key with { Generation = 2 }, host.Budget.Token); + Assert.Equal(2u, (await Receive(viewer, host.Budget.Token)).Generation); + } + [Fact] + public async Task Persistently_slow_viewer_is_closed_while_other_viewer_receives() + { + await using var host = new Host(); using var upload = await host.Connect("u"); + var config = new MediaMessage(1, 0, 1, 0, 0, 0, JsonSerializer.SerializeToUtf8Bytes(new { video = new { codec = "avc1.42e01f", width = 640, height = 360, format = "annexb" }, audio = (object?)null })); + await Send(upload, config, host.Budget.Token); await Receive(upload, host.Budget.Token); + using var slow = await host.Connect("v"); await Receive(slow, host.Budget.Token); + using var fast = await host.Connect("v"); await Receive(fast, host.Budget.Token); + var received = System.Threading.Channels.Channel.CreateUnbounded(); + var fastRead = Task.Run(async () => { + uint sequence; + do { sequence = (await Receive(fast, host.Budget.Token)).Sequence; received.Writer.TryWrite(sequence); } while (sequence < 160); + }); + var payload = new byte[700000]; + for (uint i = 1; i <= 160; i++) + { + await Send(upload, new MediaMessage(2, 1, 1, i, i * 16667, 16667, payload), host.Budget.Token); + if (i % 16 == 0) while (await received.Reader.ReadAsync(host.Budget.Token) < i) { } + } + await fastRead; + using var closeBudget = CancellationTokenSource.CreateLinkedTokenSource(host.Budget.Token); closeBudget.CancelAfter(TimeSpan.FromSeconds(3)); + await Assert.ThrowsAnyAsync(async () => { + while (await MediaSocketEndpoint.ReceiveAsync(slow, WebSocketMessageType.Binary, MediaWireCodec.MaxMessageSize, closeBudget.Token) is not null) { } + }); + } + private static Task Send(ClientWebSocket socket, MediaMessage sample, CancellationToken ct) => socket.SendAsync(MediaWireCodec.Encode(sample).AsMemory(), WebSocketMessageType.Binary, true, ct).AsTask(); + private static async Task Receive(ClientWebSocket socket, CancellationToken ct) => MediaWireCodec.Decode((await MediaSocketEndpoint.ReceiveAsync(socket, WebSocketMessageType.Binary, MediaWireCodec.MaxMessageSize, ct))!); +} diff --git a/tests/SonicRelay.MediaRelay.Tests/SonicRelay.MediaRelay.Tests.csproj b/tests/SonicRelay.MediaRelay.Tests/SonicRelay.MediaRelay.Tests.csproj new file mode 100644 index 0000000..90c5f7d --- /dev/null +++ b/tests/SonicRelay.MediaRelay.Tests/SonicRelay.MediaRelay.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file